Compare commits

...
107 Commits
Author SHA1 Message Date
Alexander KukushkinandGitHub 84aac437c1 Release v3.1.0 (#2801)
- bump pyright and resolve reported issues
- bump Patroni version
- update release notes
2023-08-03 13:02:29 +02:00
IsraelandGitHub 48e3d31e1d Refactor docs about migration to Patroni (#2796)
This PR is an attempt of refactoring the docs about migration to Patroni.

These are a few enhancements that we propose through this PR:

* Docs used to mention the procedure can only be performed in a single-node cluster. We changed that so the procedure considers a cluster composed of primary and standbys;
* Teach how to deal with pre-existing replication slots;
* Explain how to create the user for `pg_rewind`, if user intends to enable `use_pg_rewind`.

References: PAT-143.
2023-08-03 09:01:16 +02:00
Alexander KukushkinandGitHub 01d07f86cd Set permissions for files and directories created in PGDATA (#2781)
Postgres supports two types of permissions:
1. owner only
2. group readable

By default the first one is used because it provides better security. But, sometimes people want to run a backup tool with the user that is different from postgres. In this case the second option becomes very useful. Unfortunately it didn't work correctly because Patroni was creating files with owner access only permissions.

This PR changes the behavior and permissions on files and directories that are created by Patroni will be calculated based on permissions of PGDATA. I.e., they will get group readable access when it is necessary.

Close #1899
Close #1901
2023-08-02 13:15:43 +02:00
Matt BakerandGitHub b6fc4bc393 Replace instances of typing Generator[X, None, None] with Iterator[X] (#2799) 2023-08-02 12:36:29 +02:00
IsraelandGitHub 018a2f4dd9 Enhance docs of slots dynamic configuration (#2797)
The docs of `slots` configuration used to have this mention:

```
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.
```

However that is not true in the sense that Patroni does not check for
clashes between `my_slot_name` and the name of replication slots created
for replicating changes among members. If you specify a slot name that
clashes with the name of a replication slot used by a member, it turns
out Patroni will make the slot permanent in the primary even if the member
key expire from the DCS.

Through this commit we also enhance the docs in terms of explaining that
physical permanent slots are maintained only in the primary, while logical
replication slots are copied from primary to standbys.

Signed-off-by: Israel Barth Rubio <[email protected]>
2023-08-01 15:40:07 +02:00
Alexander KukushkinandGitHub b7caf3b7f2 Fix behaviour of replicas in standby cluster in pause (#2795)
When the leader key expires replicas should not follow the remote node but keep `primary_conninfo` as it is.
2023-08-01 14:09:46 +02:00
Alexander KukushkinandGitHub ec61aede85 Fix bug in the Cluster class (#2794)
The `workers` attribute when not passed explicitly was set to the same mutable `dict` object every time.

Problem was introduced in #2652
2023-08-01 13:57:46 +02:00
Alexander KukushkinandGitHub e4703d4f74 Refactor replica_list (#2790)
As suggested in
https://github.com/zalando/patroni/pull/2668/files#r1276115738, introduce a couple of classes that represent a single replica and collection of replicas.
2023-07-31 15:52:43 +02:00
IsraelandGitHub a26e46cf76 Fix replicatefrom tag in postgres2.yml (#2788)
The node `postgresql2` in the repository is apparently supposed
to be a cascading standby.

However, if that is the case, there is a typo in the name of its
upstream node.

This commit fixes that issue.
2023-07-31 15:41:14 +02:00
Alexander KukushkinandGitHub 94bfea1a81 Do not fail validation for a value that is fine (#2791)
In issue #2735 it was discussed that there should be some warning around PostgreSQL parameters that do not pass validation.

This commit ensures something is logged for parameters that fail validation and therefore fall back to default values.

Close #2735
Close #2740
2023-07-31 11:35:30 +02:00
Alexander KukushkinandGitHub 01976ec10b Don't allow stale primary to win the leader race (#2787)
Consider a following situation:
1. node1 is stressed so much that Patroni heart-beat can't run regularly and the leader lock expires.
2. node2 notice that there is no leader, gets the lock, promotes, and gets to a situation like it is described in 1.
3. Patroni on node1 finally wakes up, notice that Postgres is running as a primary, but without a leader lock and "happily" acquires the lock.

That is, node1 discarded promoting of node2, and the node2 after that it will not be possible to join the node2 back to the cluster, because pg_rewind is not possible when two nodes are on the same timeline.

To partially mitigate the problem we introduce an additional timeline check. If postgres is running as primary Patroni will consider it as a perfect candidate only if timeline isn't behind the last known cluster timeline recorder in the `/history` key. If postgres timeline is behind the cluster timeline postgres will be demoted to read-only. Further behavior would depend on `maximum_lag_on_failover` and `check_timeline` settings.

Since the `/history` key isn't updated instantly after promotion, there is still a short period of time when the issue could happen, but it seems that it is close to impossible to make it more reliable.

Close https://github.com/zalando/patroni/issues/2779
2023-07-31 11:22:18 +02:00
Alexander KukushkinandGitHub 8f3ed00886 Invalidate cache if txn failed due to revision mismatch (#2783)
It was reported in #2779 that the primary was constantly logging messages like `Synchronous replication key updated by someone else`.

It happened after Patroni was stuck due to resource starvation. Key updates are performed using create_revision/mod_revision field, which value is taken from the internal cached. Hence, it is a clear symptom of stale cache.

Similar issues in K8s implementation were addressed by invalidating the cache and restarting watcher connections every time when update failed due to resource_version mismatch, so we do the same for Etcd3.
2023-07-31 10:16:19 +02:00
Alexander KukushkinandGitHub 7e89583ec7 Please new flake8 (#2789)
it stopped liking lack of space character between `,` and `\`
```python
foo,\
    bar
```
2023-07-31 09:08:46 +02:00
Alexander KukushkinandGitHub 2735c937fd Fix pg_rewind behaviour after pause (#2776)
On Slack user reported that Patroni didn't run pg_rewind on one of the nodes after coming out of maintenance mode.
Steps that were executed:
0. The initial state: node1 - primary, node2 - replica
1. `patronictl pause`
2. On node2: pg_ctl promote
3. On node1: pg_ctl stop
4. Patroni on node1 notice that Postgres isn't running and removes the leader lock
5. Patroni on node2 notice that Postgres is running as a primary and takes the leader lock.
6. `patronictl resume`.

After that node1 started saying in logs:
`Waiting for checkpoint on node2 before rewind`.

Repeating this steps may not necessarily reproduce the problem, because presumably pg_rewind failed earlier on node1.

Such situation was possible because promote wasn't executed by Patroni and therefore `Rewind._state` wasn't explicitly reset and the code that ensures that CHECKPOINT after promote was run wasn't triggered.

As a mitigation following changes have been made:
1. retrigger pg_rewind checks after coming out of maintenance mode
2. run ensure CHECKPOINT after promote checks using `Rewind._state != REWIND_STATUS.CHECKPOINT` condition. It allowed to remove useless hook from `Postgresql.promote()`.
2023-07-27 13:39:28 +02:00
Alexander KukushkinandGitHub 384a2a4d8f Avoid unnecessary updates of /status key (#2782)
When we don't have permanent logical slots Patroni was updating the `/status` on every heart-beat loop even when LSN on the primary isn't moving forward.

The issue was introduced in the #2485
2023-07-27 13:38:24 +02:00
Alexander KukushkinandGitHub 238aba3956 Fix patronictl list (#2775)
the `Cluster` name field was missing in tsv, json, and yaml formats

The bug was introduced in #2652
2023-07-26 12:33:17 +02:00
Alexander KukushkinandGitHub ae2bbd28ae Fix in_recovery check (#2773)
The primary that is still alive wasn't properly recognized.
Regression was introduced in #2652
2023-07-25 11:50:40 +02:00
WaynervandGitHub 0e19e3e98e Make pod role label configurable (#2659)
Close #2495
2023-07-25 10:29:04 +02:00
Alexander KukushkinandGitHub 06db296612 Fixes in patroni.request (#2768)
1.  Take client certificates only from the `ctl` section. Motivation: sometimes there are server-only certificates that can't be used as client certificates. As a result neither Patroni not patronictl work correctly even if `--insecure` option is used.
2. Document that if `restapi.verify_client` is set to `required` then client certificates **must** be provided in the `ctl` section.
3.  Add support for `ctl.authentication` and prefer to use it over `restapi.authentication`.
4. Silence annoying InsecureRequestWarning when `patronictl -k` is used, so that behavior becomes is similar to `curl -k`.
2023-07-25 08:48:18 +02:00
Matt BakerandGitHub 817f39ad6d Refactor get_dcs (#2747)
Now uses generators instead of for loops and implements importing modules once.
2023-07-25 08:01:35 +02:00
Matt BakerandGitHub c5a4befdc4 Refactor check_logical_slots_readiness split to reduce complexity (#2749)
Includes:
* renaming of `_unready_logical_slots` to better represent that it is a processing queue which is emptied on successful completion.
* ensuring that return type is consistent.
* made logic variable names explicit to help explain how the decision of whether a slot is "ready" is made.
2023-07-25 08:00:59 +02:00
Polina BunginaandGitHub e860cac348 Fix manual failover/switchover checks (#2769)
In case of manual failover/switchover failover possibility should be checked only against the candidate
2023-07-24 16:23:18 +02:00
Matt BakerandGitHub 48164774c2 Refactor get replication slots (#2746)
Reduce complexity of single method and allow for documentation of distinct parts.

No functional changes have been introduced.
2023-07-24 14:57:34 +02:00
Alexander KukushkinandGitHub 0a8fb0860e Skip flaky scenario when running with Raft (#2771)
Sometimes Patroni doesn't see the latest Raft data on start.
2023-07-21 16:09:34 +02:00
Polina BunginaandGitHub ffd1ad97d2 Fix Dockerfile_s (#2770)
* Install dumb-init using apt
* Remove python 2.7 packages purge
2023-07-21 15:10:13 +02:00
WaynervandGitHub 84c574e1ec Run archive_command through shell (#2766)
Close #2764
2023-07-21 14:30:22 +02:00
Alexander KukushkinandGitHub cb9998ade6 Update docstring in do_GET_metrics() (#2765)
followup #2733
2023-07-20 13:24:17 +02:00
Alexander KukushkinandGitHub 4830e36e2b Start primary back when it crashed with "in crash recovery" state (#2763)
If one of backends crashed postmaster stops all backend and does crash recovery because shared memory could be corrupted. If something happens during this phase postgres may completely crash leaving pg_control with "in crash recovery" state.

Followup on #2726
2023-07-20 13:23:55 +02:00
Alexander KukushkinandGitHub 0c5bf3c4cd Validate more parameters in the config file (#2761)
- parameters for different DCS
- more bootstrap.dcs parameters
- ctl, restapi, and watchdog parameters
2023-07-19 12:42:14 +02:00
Stan BogatkinandGitHub 480b8dbf95 Fix typo in yml files (#2760)
Users statement was mentioned twice in templates - fix this simple typo by removing duplicates.
2023-07-17 14:55:06 +02:00
Alexander KukushkinandGitHub a4d29eb99e Release v3.0.4 (#2754)
- update release notes
- bump version
- bump pyright version
2023-07-13 11:51:38 +02:00
Alexander KukushkinandGitHub d46ca88e6b Make it visible replication state on standbys (#2733)
To do that we use `pg_stat_get_wal_receiver()` function, which is available since 9.6. For older versions the `patronictl list` output and REST API responses remain as before.

In case if there is no wal receiver process we check if `restore_command` is set and show the state as `in archive recovery`.

Example of `patronictl list` output:
```bash
$ patronictl list
+ Cluster: batman -------------+---------+---------------------+----+-----------+
| Member      | Host           | Role    | State               | TL | Lag in MB |
+-------------+----------------+---------+---------------------+----+-----------+
| postgresql0 | 127.0.0.1:5432 | Leader  | running             | 12 |           |
| postgresql1 | 127.0.0.1:5433 | Replica | in archive recovery | 12 |         0 |
+-------------+----------------+---------+---------------------+----+-----------+

$ patronictl list
+ Cluster: batman -------------+---------+-----------+----+-----------+
| Member      | Host           | Role    | State     | TL | Lag in MB |
+-------------+----------------+---------+-----------+----+-----------+
| postgresql0 | 127.0.0.1:5432 | Leader  | running   | 12 |           |
| postgresql1 | 127.0.0.1:5433 | Replica | streaming | 12 |         0 |
+-------------+----------------+---------+-----------+----+-----------+
```

Example of REST API response:
```bash
$ curl -s localhost:8009 | jq .
{
  "state": "running",
  "postmaster_start_time": "2023-07-06 13:12:00.595118+02:00",
  "role": "replica",
  "server_version": 150003,
  "xlog": {
    "received_location": 335544480,
    "replayed_location": 335544480,
    "replayed_timestamp": null,
    "paused": false
  },
  "timeline": 12,
  "replication_state": "in archive recovery",
  "dcs_last_seen": 1688642069,
  "database_system_identifier": "7252327498286490579",
  "patroni": {
    "version": "3.0.3",
    "scope": "batman"
  }
}

$ curl -s localhost:8009 | jq .
{
  "state": "running",
  "postmaster_start_time": "2023-07-06 13:12:00.595118+02:00",
  "role": "replica",
  "server_version": 150003,
  "xlog": {
    "received_location": 335544816,
    "replayed_location": 335544816,
    "replayed_timestamp": null,
    "paused": false
  },
  "timeline": 12,
  "replication_state": "streaming",
  "dcs_last_seen": 1688642089,
  "database_system_identifier": "7252327498286490579",
  "patroni": {
    "version": "3.0.3",
    "scope": "batman"
  }
}
```
2023-07-13 09:24:20 +02:00
Matt BakerandGitHub 665f49b320 Refactor _copy_items (#2748)
Just a reformat to aid readability.
2023-07-12 10:15:19 +02:00
Matt BakerandGitHub 47854d77e8 Refactor allowed_keys (#2745)
Refactor allowed_keys method as a class variable

Method does not perform any computation or modify data as it is a static
tuple, therefore it is better expressed as a class variable.
2023-07-12 09:55:33 +02:00
Alexander KukushkinandGitHub e4fe239a9d A few fixes in synchronous_mode (#2741)
- make sure that physical replication slots are created even before the promote happened (when async executor is busy with promote).
- execute `txid_current()` with `synchronous_commit=off` so it doesn't accidentally wait for absent synchronous standbys when `synchronous_mode_strict` is enable and `synchronous_standby_names=*`. These standbys can't connect because replication slots weren't there.
- `synchronous_standby_names` wasn't set to `*` after bootstrap with `synchronous_mode` and `synchronous_mode_strict`.
- add `-c statement_timeout=0` to `PGOPTIONS` when executing `post_bootstrap` script.

Close https://github.com/zalando/patroni/issues/2738
2023-07-12 09:43:40 +02:00
Alexander KukushkinandGitHub 6e96db173f Start postgres not in recovery in some cases (#2726)
If we know for sure that a few moments ago postgres was still running as a primary and we still have the leader lock and can successfully update it, in this case we can safely start postgres back not in recovery. That will allow to avoid bumping timeline without a reason and hopefully improve reliability because it will address issues similar to #2720.

In addition to that remove `if self.state_handler.is_starting()` check from the `recover()` method. This branch could never be reached because the `starting` state is handled earlier in the `_run_cycle()`. Besides that remove redundant `self._crash_recovery_executed`.

P.S. now we do not cover cases when Patroni was killed along with Postgres.
Lets consider that we just started Patroni, there is no leader, and `pg_controldata` reports `Database cluster state` as `shut down`. It feels logical to use `Latest checkpoint location` and `Latest checkpoint's TimeLineID` to do a usual leader race and start directly as a primary, but it could be totally wrong. The thing is that we run `postgres --single` if standby wasn't shut down cleanly before executing `pg_rewind`. As a result `Database cluster state` transition from `in archive recovery` to `shut down`, but if such a node becomes a leader the timeline must be increased.
2023-07-12 09:42:34 +02:00
Alexander KukushkinandGitHub b8cff3515a Reduce flakiness of citus behave tests, take 2 (#2742)
Reorder some checks and verify that the old primary is already in the `running` state before checking replication. This check elliminates the race condition when replication started to work but node name is removed from the `synchronous_standby_names` because state isn't `running`.
2023-07-11 15:04:10 +02:00
Mark PekalaandGitHub 412c51ddf1 Prevent splitbrain from duplicate names in configuration (#2724)
When starting check if node with the same is registered in DCS and try to query it's REST API.
If REST API is accessible exit with the error.

Close #1804
2023-07-11 07:43:57 +02:00
Feike SteenbergenandGitHub 4725f12f9a Allow integer gucs without units in validation (#2734)
Previously, integer gucs, for example `max_connections` would not pass the validation, as these settings have no unit, if and only if they were specified as a string.

This causes problems if the `max_connections` is configured in `patroni.yaml` as a string, for example, the following configuration would not result in the right `max_connections` settings, as `max_connections` is configured as a string:

    bootstrap:
      dcs:
        postgresql:
          parameters:
            log_checkpoints: "on"
            log_connections: "off"
            max_connections: "57"

Allowing a user to specify *all* parameters as a string was accepted before in Patroni and also seems very useful, as many of us will be using Ansible/Helm/Golang to build a Patroni configuration, in which creating a `map[string]string` is easier than having to deal with data types.

Attemps to address issue #2735 

Regression was introduced in https://github.com/zalando/patroni/commit/76b3b99de2f2bfaa8ab2df9e47dbfc3749d14e84
2023-07-10 13:44:54 +02:00
Alexander KukushkinandGitHub 3c1b274ab7 Use quorum read in patronictl if it is possible (#2730)
implementations and terminologis are DCS specific:
- Etcd v2 calls is `quorum` read
- Etcd v3 calls it `linearizable` (vs `serializable`)
- Consul calls it `consistent`

Following DCS don't offer this feature:
- ZooKeeper calls it linearizable, but reads are sequentially consistent
- Raft - no quorum reads are possible ATM
- Kubernetes - uses Etcd under the hood, but provides no API to choose read consistency level

Close https://github.com/zalando/patroni/issues/1199
2023-07-10 09:19:43 +02:00
Alexander KukushkinandGitHub 35c97fa402 Make sure the version_prefix for etcd3 is set to /v3beta (#2729)
Setting it before calling the parent constructor didn't really work because it is being overwritten in the `etcd.Client.__init__()`.

The only viable way of doing it is passing a custom value to the parent class.

Close https://github.com/zalando/patroni/issues/2142
2023-07-10 09:19:10 +02:00
Alexander KukushkinandGitHub 1c36112b44 Reduce flakiness of citus behave tests (#2728)
* Reduce flakiness of citus behave tests

- make a few attempts with timeout  when checking registered nodes
- get rid from artificial sleep
- allow check_registration() function to check secondaries

These changes are useful for Quorum based failover (#2668) and future PR
that enhances Citus support by registering secondaries in `pg_dist_node`.
2023-07-07 15:23:04 +03:00
Alexander KukushkinandGitHub 768d563fba Check py files in features with flake8 (#2737)
They are correctly formatted and there is no reason not to enforce it.
2023-07-07 11:27:59 +02:00
Matt BakerandGitHub 4b023bc9ad Set encoding on open call in setup.py (#2727)
* Set encoding on open call in setup.py

If a host OS does not have a UTF-8 locale set the read() call is unable
to read the utf-8 encoded README.rst file.

* Remove non-ASCII characters from README.rst
2023-07-07 12:19:41 +03:00
Mark PekalaandGitHub c4f8e72765 Update .gitignore to include common venv/data patterns (#2732)
Close #2731
2023-07-07 09:57:17 +02:00
Alexander KukushkinandGitHub 0eea239f6b Compatibility with click==8.1.4 (#2736)
They somehow messed up with type hints what made pyright unhappy.
To solve it we explicitly pass the Group class to the group() decorator.

In addition to that bump pyright version.
2023-07-07 09:33:30 +02:00
Martín MarquésandGitHub e72d3ba79e Use full names for contributors in the release notes (#2725)
Until the last release, contributors' names were fully written on the
first occurence during that release. This meant that if Alexander had
four contributions in the release, we would use Alexander Kukushkin on
the first item in the release, and on all the others just Alexander.

This could, in some cases, create some confusion. For example, if there
are more than one contributor with the same first name that has more
than one contribution each.

For this reason, in release 3.0.3, we used the full names of contributors
on all the items from the release.

This patch is to amend the old release notes and have each entry with the
full name of the contributor.

Also fix typo with 2 spaces between first name and last name in one bug fix

Signed-off-by: Martín Marqués <[email protected]>
2023-07-04 18:53:53 +03:00
IsraelandGitHub ed02826103 REST API would not reload SSL certificate upon receiving an SIGHUP (#2722)
Revert to using `ssl._ssl._test_decode_cert`

A change has been included as part of Patroni 3.0.3 release: use
public functions instead of `ssl._ssl._test_decode_cert` to get
serial number of certificates.

There was a slight bug in that implementation: it was only loading
the certificates through `load_verify_locations`, but was missing
to get the certificates through `get_ca_certs`. As a consequence
Patroni was not able anymore to reload REST API cert on SIGHUP.

An attempt to fix that issue was made through commit
`20f578f09f3aa604e5288710d4fd4e611152ed5f`. However, even with the
correct call of `get_ca_certs`, it was detected a corner case where
`load_verify_locations` would skip loading a certificate: if it was
issued with `CA:FALSE`. That essentially means the implementation is
still buggy in that situation. See [CPython](https://github.com/python/cpython/blob/c283a0cff5603540f06d9017e484b3602cc62e7c/Modules/_ssl.c#L4618C14-L4619)
for the underlying problem.

In order to get back a functional implementation again we are reverting
the code to use the private function `ssl._ssl._test_decode_cert`.

We can later study a possible more elegant alternative for solving this,
if any.

---------

Signed-off-by: Israel Barth Rubio <[email protected]>
2023-07-04 18:53:24 +03:00
AndreyandGitHub 74d78dbba2 Update request_queue_size feature authors (#2723)
Add Aleksei Sukhov do the authors
2023-06-26 08:11:09 +02:00
Alexander KukushkinandGitHub 6f91f4f4e2 Release v3.0.3 (#2719)
* Bump version
* Bump pyright version and fix newly reported issues
* Update release notes
* Fix typos, extend release process desc
* Add readthedocs configuration file v2
* Fix Dockerfile.citus files
2023-06-22 10:46:02 +02:00
Mark PekalaandGitHub 43e2290fdf More beginner-friendly introduction (#2712)
Attempts to make progress on #2250 by rephrasing existing introduction and making sentences slightly shorter.
2023-06-21 11:45:56 +02:00
Alexander KukushkinandGitHub a00ffcb1a6 Update GUC's validator for PG16 beta1 (#2716)
Plenty of GUC's were added, and some removed.
`force_parallel_mode` was renamed to `debug_parallel_query`, but it is not worth special handling.
2023-06-21 09:58:29 +02:00
Alexander KukushkinandGitHub 9b01041175 Compatibility with python 3.11 (#2718)
now it checks that inside square brackets there is indeed IPv6.

In addition to that fix a little issue in the function itself so it returns exactly the same result as psycopg2.extensions.parse_dsn().

Close https://github.com/zalando/patroni/pull/2714
2023-06-21 09:48:55 +02:00
Alexander KukushkinandGitHub 2354f8f004 Fix a few concurrency bugs in Citus support (#2710)
- the `_in_fligh` attribute is accessed from multiple threads and must be protected with mutex when it is changed
- allow adding tasks for `_in_fligh.group` from the `sync_pg_dist_node()` method when timeout is reached. Not doing so might result is indefinite transaction if REST API request from worker node failed.
2023-06-12 07:52:46 +02:00
IsraelandGitHub bd951ccdef Add docstrings to patroni.async_executor (#2704)
References: PAT-120.
2023-06-09 14:07:06 +02:00
IsraelandGitHub e9f9e1cfad Add docstrings to patroni.exceptions (#2703) 2023-06-06 11:02:44 +03:00
IsraelandGitHub 4e52d4bb2e Add docstrings to patroni.collections (#2702) 2023-06-06 11:01:32 +03:00
IsraelandGitHub 0cf2083161 Add docstrings to patroni.__init__ (#2698)
References: PAT-111
2023-06-06 08:36:29 +02:00
IsraelandGitHub 4b960477bb Add docstrings to patroni.ctl (#2687)
References: PAT-90.
2023-06-06 08:21:59 +02:00
Polina BunginaandGitHub 21e92fd166 Add env vars for custom bin names (#2706) 2023-06-01 14:06:11 +02:00
Alexander KukushkinandGitHub af318b2473 Fix kubernetes behave tests (#2707)
Starting from 1.27 there is containerd process, which also uses k3s binary and being detected by pidof. Therefore we will search for "k3s server" string in the process list instead of just "k3s".
2023-06-01 13:28:29 +02:00
f3c80d5706 Fix a minor error building a docker image for citus (#2705)
This handles the following syntax error.

$ docker build -t patroni-citus -f Dockerfile.citus .
(snip)
  => ERROR [builder 2/3] RUN set -ex     && export DEBIAN_FRONTEND=noninteractive     && echo  0.5s -
(snip)
  #5 0.456 /bin/sh: 1: Syntax error: end of file unexpected (expecting "fi")

Co-authored-by: Masahiro Ikeda <[email protected]>
2023-05-31 21:22:30 +02:00
IsraelandGitHub df18885f20 Extend Postgres GUCs validator (#2671)
* Use YAML files to validate Postgres GUCs through Patroni.

Patroni used to have a static list of Postgres GUCs validators in
`patroni.postgresql.validator`.

One problem with that approach, for example, is that it would not
allow GUCs from custom Postgres builds to be validated/accepted.

The idea that we had to work around that issue was to move the
validators from the source code to an external and extendable source.
With that Patroni will start reading the current validators from that
external source plus whatever custom validators are found.

From this commit onwards Patroni will read and parse all YAML files
that are found under the `patroni/postgresql/available_parameters`
directory to build its Postgres GUCs validation rules.

All the details about how this work can be found in the docstring
of the introduced function `_load_postgres_gucs_validators`.
2023-05-31 13:54:54 +02:00
IsraelandGitHub d11328020d Add support for custom Postgres binary names (#2692)
When using a custom Postgres distribution it may be the case that the Postgres binaries are compiled with different names other than the ones used by the community Postgres distribution.

With that in mind we implemented a new set of settings for Patroni, so the user is able to override the default binary names with custom binary names through the new section postgresql.bin_name in the local configuration.

References: PAT-17.
2023-05-30 13:57:57 +02:00
37fffa618f Refactor daemon entrypoints (#2697)
- abstract_main only creates Config object using the passed configfile
  and instantiates the passed daemon class
- common args parser is extracted into a separate func that is called
  from daemons' main funcs (specific args can be added afterwards)

Co-authored-by: Alexander Kukushkin <[email protected]>
2023-05-26 15:13:04 +02:00
Alexander KukushkinandGitHub 101ea10e98 Introduce Retry.ensure_deadline() method (#2694)
it helps to get rid of recurring patterns:
```python
retry.deadline = retry.stoptime - time.time()
if retry.deadline < XXX:
    raise Exception(...) or return False
```
2023-05-26 11:04:58 +02:00
Matt BakerandGitHub 2158f4a87b Add base image build arg for alt postgres (#2695)
Allows for running behave tests with an alternative base image
than the official postgres image.

Also provides a PG_USER/PG_GROUP should that be different to the
default `postgres`.
2023-05-26 09:35:12 +02:00
Alexander KukushkinandGitHub af8e5f0d0f Refactor update_leader interface (#2690)
pass reference to a last known leader object in order to avoid obtaining it from the `AbstractDCS.cluster` cache.

This change is useful for Consul, Etcd3 and Zookeeper implementations.
2023-05-25 14:21:05 +02:00
Alexander KukushkinandGitHub 1c7bf2f59e Fix a problem with etcd3.update_leader() (#2693)
It didn't took into account the fact that we can get a new lease after changing a TTL. In this case we have to update the exiting leader key with the new lease.
To solve it we introduce the on transaction 'failure' callback. The whole workflow looks like (schematically):
```python
txn(
    compare=(old_value == self._name)),
    success=put(self.leader_path, self._name, self._lease),
    failure=txn(
        compare=(create_revision == '0'),
        success=put(self.leader_path, self._name, self._lease)
    )
)
```

The problem was introduced in d98d6d0b02c9cc67464a7b1b31b1c5570c26e12d
2023-05-25 10:01:43 +02:00
Polina BunginaandGitHub 822b6ec711 Subtle README fix (#2691)
Remove misleading words
2023-05-24 11:22:41 +02:00
Matt BakerandGitHub 73797e8572 Add tox configuration for running multiple test envs (#2603) 2023-05-24 10:58:04 +02:00
Alexander KukushkinandGitHub b4afc6830b Little fixes in etcd3 and kubernetes (#2689)
- Always pass etcd3 key revision as a string
- Make sure the leader key isn't unconditionally overwritten. It may happen that the leader heart-beat loop didn't run properly and the session has expired. In this case the leader may create a new session and a new leader key. But, there are chances that the other node already created a leader key and we don't want to overwrite it.
- Try to sync HA loops between nodes by adding 0.5 seconds to timeout on non-leader nodes
2023-05-24 10:54:26 +02:00
Polina BunginaandGitHub d1fdb45179 Make bootstrap.initdb optional (#2685) 2023-05-24 09:28:57 +02:00
6c8a3b0d25 Remove bootstrap.pg_hba (#2684)
* Remove bootstrap.pg_hba
* Extend docs for postgresql.pg_hba/pg_ident
* Add postgresql.pg_hba/pg_ident to dynamic config docs

---------

Co-authored-by: Alexander Kukushkin <[email protected]>
2023-05-24 09:01:56 +02:00
IsraelandGitHub 0ead20f6a4 Remove __str__ method from PatroniException. (#2688)
The `__str__` method was calling `repr` over the exception message,
which was causing `print` calls to render not so nicely, e.g.:

```
$ patronictl show-config
Error: 'Can not find suitable configuration of distributed configuration store\nAvailable implementations: consul, etcd, etcd3, exhibitor, kubernetes, raft, zookeeper'
```

By removing the `__str__` method we get a better rendering, e.g.:

```
$ patronictl show-config
Error: Can not find suitable configuration of distributed configuration store
Available implementations: consul, etcd, etcd3, exhibitor, kubernetes, raft, zookeeper
```

References: PAT-107.

Signed-off-by: Israel Barth Rubio <[email protected]>
2023-05-23 17:05:53 +02:00
Polina BunginaandGitHub 2f5bcbd877 Change PostgreSQL Slack invite link (#2680) 2023-05-23 08:17:51 +02:00
Polina BunginaandGitHub db71ba3955 Fix dev Dockerfile.citus for arm (#2683)
- Fix dev Dockerfile.citus for arm
Don't purge lib required for citus run

* Change citus repo url, update citus version
2023-05-22 16:15:40 +02:00
Polina BunginaandGitHub e0a4a0c6a6 Fix pyright complaints about partner_addrs, change Citus repo URL in CI (#2682)
* Fix pyright complaints about partner_addrs
* Pin pyright version in workflow
* Change Citus repo URL in CI
2023-05-22 15:30:24 +02:00
Polina BunginaandGitHub 506b5bec48 Validate-config fixes (#2678)
- fix --validate-config not to error out if bin_dir is an empty string in the yaml config
- mention bin_dir optionality in the docs
- validate bin_dir even if it is not in the yaml config (add optional
  default value for Optional config params in validator)
- make rewind user optional
2023-05-15 13:40:22 +02:00
Polina BunginaandGitHub 44e58a1ba1 Dev docker images improvements (#2677)
- configurable image
- ETCD_UNSUPPORTED_ARCH env in docker-compose
- Build confd and citus for arm64 images
2023-05-15 11:40:35 +02:00
Alexander KukushkinandGitHub 66a0e44371 Enable pyright job for every commit (#2675)
And fix remaining issues that the job doesn't fail.
2023-05-15 11:38:40 +02:00
IsraelandGitHub fdcf8b1997 Add docstrings and type hints to patroni/api.py (#2648)
References: PAT-77
2023-05-12 15:38:59 +02:00
Polina BunginaandGitHub ab9fea7d6b Fix openssl certificate generation in behave tests (#2672)
--addext -> -addext (doesn't work on macOS)
set keyfile permissions to 600 (to avoid "private key file has group or world access")
2023-05-12 10:42:53 +02:00
Alexander KukushkinandGitHub 7941c86775 Refactor write_sync_state() (#2669)
Make it return the new `SyncState` object in order to avoid reading the new cluster state in the Ha.process_sync_replication().

Now it is a small optimization, but it will become very handy in the quorum commit feature.
2023-05-11 09:58:15 +02:00
Alexander KukushkinandGitHub 13164daf28 More typing in sync.py (#2666)
- make parse_sync_standby_names() return NamedTuple instead of dict
- fix little issue in Postgresql.reset_cluster_info_state(), it should check global_config independently from cluster and cluster.config
2023-05-09 12:47:34 +02:00
Alexander KukushkinandGitHub 76b3b99de2 Enable pyright strict mode (#2652)
- added pyrightconfig.json with typeCheckingMode=strict
- added type hints to all files except api.py
- added type stubs for dns, etcd, consul, kazoo, pysyncobj and other modules
- added type stubs for psycopg2 and urllib3 with some little fixes
- fixes most of the issues reported by pyright
- remaining issues will be addressed later, along with enabling CI linting task
2023-05-09 09:38:00 +02:00
Polina BunginaandGitHub 1ac9b11f33 Remove watchdog from __DEFAULT_CONFIG (#2660) 2023-05-04 08:37:39 +02:00
Polina BunginaandGitHub 4e1b9937b9 Documentation improvements (#2661)
* Further nested lists rendering fixes
* Remove a couple of sphinx warnings
* Fix bootstrap.users.password description
* Boto->boto3 in README's
* Split configuration docs and move some lines across files
* Fix a typo
2023-05-04 07:24:37 +02:00
nrmn_2492andGitHub 90ed581d87 Update pg_rewind user/password to optional in docs (#2650)
* Update REWIND_USERNAME, REWIND_PASWWORD optional tag in ENVIRONMENT.rst file
* Update settings.rst pg_rewind user/password optional
2023-05-03 15:39:09 +02:00
Le DuaneandGitHub bebe6754fc Add before stop hook (#2642)
The two cases we have in mind are:
* In spite of following all best practices client-side, logical replication connections can sometimes hang the Postgres shutdown sequence. We'd like to sigterm any misbehaving logical replication connections which remain after x seconds. These will inevitably get killed anyway on master stop timeout.
* remove "role=master" label on current primary when not using k8s as DCS. Waiting until after Postgres fully stops can sometimes be too long for this.
* Pause pgbouncer connections before switchover

Close #2596
2023-04-27 13:07:32 +02:00
Alexander KukushkinandGitHub 4d35f85b87 Fix behave tests (#2656)
1. specify `subjectAltName=IP:127.0.0.1` when generating certificate
2. run more behave tests with psycopg2
2023-04-27 12:18:44 +02:00
Chris BandyandGitHub 54b6d8186f Render nested lists correctly in settings docs (#2649)
The sections on this page have been rendering as description lists
rather than unordered lists.
2023-04-26 14:42:57 +02:00
Matt BakerandGitHub ff6d728f07 Call initdb directly (#2633)
Previously it was called via `pg_ctl`, what required a special quoting of parameters passed to `initdb`.
Co-authored-by: Israel <[email protected]>
2023-04-24 09:35:39 +02:00
IsraelandGitHub 4f458baa0e Add docstrings and type annotations to patroni/psycopg.py (#2634)
References: PAT-72
2023-04-13 16:30:14 +02:00
IsraelandGitHub 7c0a565985 Add docstrings and type annotations to patroni/request.py (#2635)
References: PAT-73
2023-04-13 15:23:41 +02:00
IsraelandGitHub 782ebda77e Add docstrings and type annotations to patroni/log.py (#2636)
References: PAT-74
2023-04-13 14:32:37 +02:00
Alexander KukushkinandGitHub 24af774adb Attempt to reduce behave flakiness on MacOS (#2645)
Sometimes MacOS workers are so slow that Postgres shutdown might take more than 30s-40s, what breaks a test with replica reinit in parallel with the primary restart, because basebackup() does only two attempts and in a pause the replica remains running with empty PGDATA.

In addition to that increase timeouts in ignore_slots test.
Close #2637
2023-04-13 12:21:08 +02:00
AndreyandGitHub 8a5d6ec74d Add "request_queue_size" option to REST API server (#2643)
Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
2023-04-12 10:25:14 +02:00
IsraelandGitHub 6ffc73946a Cover etcd3 in parse_dcs function (#2639)
Previous to this commit the `parse_dcs` function would fail with a `PatroniCtlException` if the user ever passed an `etcd3` URL through `--dcs-url` command-line option.

As a consequence, the only way of using `etcd3` in `patronictl` was by using a configuration file passed through `-c` command-line option.

This commit fixes that issue and allows `etcd3` to be used in `--dcs-url`.

References: PAT-91
Close #2638
2023-04-12 09:02:32 +02:00
Alexander KukushkinandGitHub e30d96a468 More use of CaseInsensitiveSet (#2631)
1. make `SyncHandler.current_state()` return `CaseInsensitiveSet` instead of `list` objects.
2. take `sync_node_count` and `sync_node_maxlag` from the `Postgresql._global_config` instead of passing them as arguments.
3. Make `AbstractDCS.write_sync_state()` accept any `Collection`-like objects.
2023-04-05 15:30:55 +02:00
Alexander KukushkinandGitHub 2c7b547a29 Introduce patroni.collections (#2629)
For now it implements:
- CaseInsensitiveDict()
- CaseInsensitiveSet()

Update `patroni.postgresql.sync.parse_sync_standby_names()` to use `CaseInsensitiveSet()` instead of `CaseInsensitiveDict()`
2023-04-03 11:19:08 +02:00
3fe2a7868a Ignore D401 in flake8-docstrings (#2627)
* Ignore D401 in flake8-docstrings
* Fix newly reported flake8 issues, ignore the old W503 rule
* rely on concatenation of adjecent strings
* Format behave scripts
* Reformat ha.py according to new rules

Co-authored-by: Alexander Kukushkin <[email protected]>
2023-04-03 09:52:22 +02:00
Alexander KukushkinandGitHub 6f357a4e17 Factor out global configuration into a dedicated class (#2628)
It will help to avoid code duplications.
2023-04-03 08:09:29 +02:00
IsraelandGitHub c549ea7d5c Add docstrings and type annotations to patroni/utils.py (#2624)
References: PAT-43
2023-03-30 08:36:13 +02:00
Alexander KukushkinandGitHub 1003af6d20 Compatibility with python 3.6 (#2626)
despite being EOL the number of downloads from pypi for 3.6 is on the first place, hence we need to support it.

Fixed a couple of problems in tests:
1. pytest complains about `call` imported from mock, mock.call() is fine
2. one test of Citus intergration was broken
2023-03-29 16:39:01 +02:00
IsraelandGitHub 786f7eba97 Add docstrings and type annotations to patroni/validator.py (#2612)
References: PAT-42
2023-03-28 07:37:28 +02:00
Alexander KukushkinandGitHub f42bab5081 Improve behaviour of SyncState.matches() (#2619)
Previously it used to compare between the leader and sync_standbys, while in some cases (actually most of them) the leader should be excluded.
This commit makes `matches()` method flexible:
1. The leader will be included to comparison only if requested
2. checks will be performed as case insensitive (like PG does)

Besides that, everywhere in code start using `cluster.sync.matches()` instead of `name in cluster.sync.members`.
2023-03-28 07:36:45 +02:00
166 changed files with 15333 additions and 5143 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
If you have a question please post it on channel [#patroni](https://postgresteam.slack.com/archives/C9XPYG92A) in the [PostgreSQL Slack](https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA). If you have a question please post it on channel [#patroni](https://postgresteam.slack.com/archives/C9XPYG92A) in the [PostgreSQL Slack](https://pgtreats.info/slack-invite).
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**! Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
Please fill the form below and provide as much information as possible. Please fill the form below and provide as much information as possible.
Not doing so may result in your bug not being addressed in a timely manner. Not doing so may result in your bug not being addressed in a timely manner.
+1 -1
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Question - name: Question
url: https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA url: https://pgtreats.info/slack-invite
about: "Please ask questions on channel #patroni in the PostgreSQL Slack" about: "Please ask questions on channel #patroni in the PostgreSQL Slack"
+3 -3
View File
@@ -19,7 +19,7 @@ def install_requirements(what):
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave'] requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
requirements += ['coverage'] requirements += ['coverage']
# try to split tests between psycopg2 and psycopg3 # try to split tests between psycopg2 and psycopg3
requirements += ['psycopg[binary]'] if sys.version_info > (3, 7, 0) and\ requirements += ['psycopg[binary]'] if sys.version_info >= (3, 8, 0) and\
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary'] (sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
for r in read('requirements.txt').split('\n'): for r in read('requirements.txt').split('\n'):
r = r.strip() r = r.strip()
@@ -85,8 +85,8 @@ def unzip_all(archive):
def chmod_755(name): def chmod_755(name):
os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
def unpack(archive, name): def unpack(archive, name):
+19 -2
View File
@@ -116,8 +116,8 @@ jobs:
sudo apt-get install -y wget ca-certificates gnupg debian-archive-keyring apt-transport-https sudo apt-get install -y wget ca-certificates gnupg debian-archive-keyring apt-transport-https
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
sudo sh -c 'wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg' sudo sh -c 'wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg'
sudo sh -c 'echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://repos.citusdata.com/community/ubuntu/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list' sudo sh -c 'echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/ubuntu/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list'
sudo sh -c 'wget -qO - https://repos.citusdata.com/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg' sudo sh -c 'wget -qO - https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg'
if: matrix.os == 'ubuntu' if: matrix.os == 'ubuntu'
- name: Install dependencies - name: Install dependencies
run: python .github/workflows/install_deps.py run: python .github/workflows/install_deps.py
@@ -157,3 +157,20 @@ jobs:
steps: steps:
- run: bash <(curl -Ls https://coverage.codacy.com/get.sh) final - run: bash <(curl -Ls https://coverage.codacy.com/get.sh) final
if: ${{ env.SECRETS_AVAILABLE == 'true' }} if: ${{ env.SECRETS_AVAILABLE == 'true' }}
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Install dependencies
run: python -m pip install -r requirements.txt psycopg2-binary psycopg
- uses: jakebailey/pyright-action@v1
with:
version: 1.1.320
+6
View File
@@ -57,3 +57,9 @@ docs/source/_templates/
#VSCode IDE #VSCode IDE
.vscode/ .vscode/
# Virtual environment
venv*/
# Default test data directory
data/
+21
View File
@@ -0,0 +1,21 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
formats:
- epub
- pdf
- htmlzip
+21 -9
View File
@@ -25,8 +25,7 @@ RUN set -ex \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \ | grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \ | xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \ python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping --fix-missing \ net-tools iputils-ping dumb-init --fix-missing \
&& pip3 install dumb-init \
\ \
# Cleanup all locales but en_US.UTF-8 # Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \ && find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
@@ -53,14 +52,26 @@ RUN set -ex \
&& curl -sL "https://github.com/coreos/etcd/releases/download/v$ETCDVERSION/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \ && curl -sL "https://github.com/coreos/etcd/releases/download/v$ETCDVERSION/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \ | tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
\ \
# Download confd && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \ # Build confd
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \ apt-get install -y git make \
&& curl -sL https://go.dev/dl/go1.20.4.linux-arm64.tar.gz | tar xz -C /usr/local go \
&& export GOROOT=/usr/local/go && export PATH=$PATH:$GOROOT/bin \
&& git clone --recurse-submodules https://github.com/kelseyhightower/confd.git \
&& make -C confd \
&& cp confd/bin/confd /usr/local/bin/confd \
&& rm -rf /confd /usr/local/go; \
else \
# Download confd
curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd; \
fi \
\ \
# Clean up all useless packages and some files # Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \ && apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \ libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \ exim4-config gnupg-agent dirmngr \
git make \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@@ -143,14 +154,15 @@ WORKDIR $PGHOME
RUN sed -i 's/env python/&3/' /patroni*.py \ RUN sed -i 's/env python/&3/' /patroni*.py \
# "fix" patroni configs # "fix" patroni configs
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \ && sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \ && sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \ && sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \ && sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
&& sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \ && sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| connect_address\| parameters\):/#&/' postgres?.yml \
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \ && sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
&& sed -i 's/^ parameters:/ pg_hba:\n - local all all trust\n - host replication all all md5\n - host all all all md5\n&\n max_connections: 100/' postgres?.yml \ && sed -i 's/^ parameters:/&\n max_connections: 100/' postgres?.yml \
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' postgres?.yml \
&& sed -i 's/^\(.*\) \(.*\) md5/\1 all md5/' postgres?.yml \
&& if [ "$COMPRESS" = "true" ]; then chmod u+s /usr/bin/sudo; fi \ && if [ "$COMPRESS" = "true" ]; then chmod u+s /usr/bin/sudo; fi \
&& chmod +s /bin/ping \ && chmod +s /bin/ping \
&& chown -R postgres:postgres "$PGHOME" /run /etc/haproxy && chown -R postgres:postgres "$PGHOME" /run /etc/haproxy
+39 -12
View File
@@ -20,15 +20,28 @@ RUN set -ex \
&& export DEBIAN_FRONTEND=noninteractive \ && export DEBIAN_FRONTEND=noninteractive \
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \ && echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
&& apt-get update -y \ && apt-get update -y \
# postgres:10 is based on debian, which has the patroni package. We will install all required dependencies # postgres:PG_MAJOR is based on debian, which has the patroni package. We will install all required dependencies
&& apt-cache depends patroni | sed -n -e 's/.*Depends: \(python3-.\+\)$/\1/p' \ && apt-cache depends patroni | sed -n -e 's/.*Depends: \(python3-.\+\)$/\1/p' \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \ | grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \ | xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \ python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping --fix-missing \ net-tools iputils-ping lsb-release dumb-init --fix-missing \
&& curl https://install.citusdata.com/community/deb.sh | bash \ && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.2 \ apt-get install -y postgresql-server-dev-$PG_MAJOR \
&& pip3 install dumb-init \ git gcc make autoconf \
libc6-dev flex libcurl4-gnutls-dev \
libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev\
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& git clone -b "main" https://github.com/citusdata/citus.git \
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
else \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
fi \
\ \
# Cleanup all locales but en_US.UTF-8 # Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \ && find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
@@ -55,16 +68,29 @@ RUN set -ex \
&& curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-$(dpkg --print-architecture).tar.gz \ && curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-$(dpkg --print-architecture).tar.gz \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \ | tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
\ \
# Download confd && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& curl -sL https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-$(dpkg --print-architecture) \ # Build confd
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \ curl -sL https://go.dev/dl/go1.20.4.linux-arm64.tar.gz | tar xz -C /usr/local go \
&& export GOROOT=/usr/local/go && export PATH=$PATH:$GOROOT/bin \
&& git clone --recurse-submodules https://github.com/kelseyhightower/confd.git \
&& make -C confd \
&& cp confd/bin/confd /usr/local/bin/confd \
&& rm -rf /confd /usr/local/go; \
else \
# Download confd
curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd; \
fi \
# Prepare client cert for HAProxy # Prepare client cert for HAProxy
&& cat /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/certs/ssl-cert-snakeoil.pem > /etc/ssl/private/ssl-cert-snakeoil.crt \ && cat /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/certs/ssl-cert-snakeoil.pem > /etc/ssl/private/ssl-cert-snakeoil.crt \
\ \
# Clean up all useless packages and some files # Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \ && apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \ libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \ exim4-config gnupg-agent dirmngr \
postgresql-server-dev-$PG_MAJOR git gcc make autoconf \
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@@ -149,16 +175,17 @@ WORKDIR $PGHOME
RUN sed -i 's/env python/&3/' /patroni*.py \ RUN sed -i 's/env python/&3/' /patroni*.py \
# "fix" patroni configs # "fix" patroni configs
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \ && sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \ && sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \ && sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \ && sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
&& sed -i 's/^scope:/log:\n loggers:\n patroni.postgresql.citus: DEBUG\n#&/' postgres?.yml \ && sed -i 's/^scope:/log:\n loggers:\n patroni.postgresql.citus: DEBUG\n#&/' postgres?.yml \
&& sed -i 's/^\(name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \ && sed -i 's/^\(name\|etcd\| host\| authentication\| connect_address\| parameters\):/#&/' postgres?.yml \
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \ && sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
&& sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' postgres?.yml \ && sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' postgres?.yml \
&& sed -i 's|^ parameters:| pg_hba:\n - local all all trust\n - hostssl replication all all md5 clientcert=verify-ca\n - hostssl all all all md5 clientcert=verify-ca\n&\n max_connections: 100\n shared_buffers: 16MB\n ssl: "on"\n ssl_ca_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key\n citus.node_conninfo: "sslrootcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslkey=/etc/ssl/private/ssl-cert-snakeoil.key sslcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslmode=verify-ca"|' postgres?.yml \ && sed -i 's|^ parameters:|&\n max_connections: 100\n shared_buffers: 16MB\n ssl: "on"\n ssl_ca_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key\n citus.node_conninfo: "sslrootcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslkey=/etc/ssl/private/ssl-cert-snakeoil.key sslcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslmode=verify-ca"|' postgres?.yml \
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' postgres?.yml \
&& sed -i 's/^\(.*\) \(.*\) \(.*\) \(.*\) \(.*\) md5.*$/\1 hostssl \3 \4 all md5 clientcert=verify-ca/' postgres?.yml \
&& sed -i 's/^#\(ctl\| certfile\| keyfile\)/\1/' postgres?.yml \ && sed -i 's/^#\(ctl\| certfile\| keyfile\)/\1/' postgres?.yml \
&& sed -i 's|^# cafile: .*$| verify_client: required\n cafile: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \ && sed -i 's|^# cafile: .*$| verify_client: required\n cafile: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \
&& sed -i 's|^# cacert: .*$| cacert: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \ && sed -i 's|^# cacert: .*$| cacert: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \
+3 -3
View File
@@ -8,7 +8,7 @@ You can find a version of this documentation that is searchable and also easier
There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful. Patroni is a template for high availability (HA) PostgreSQL solutions using Python. For maximum accessibility, Patroni supports a variety of distributed configuration stores like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in datacenters - or anywhere else - will hopefully find it useful.
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
@@ -49,7 +49,7 @@ We report new releases information `here <https://github.com/zalando/patroni/rel
Community Community
========= =========
There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA>`__. If you're using Patroni, or just interested, please join us. There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__. If you're using Patroni, or just interested, please join us.
=================================== ===================================
Technical Requirements/Installation Technical Requirements/Installation
@@ -119,7 +119,7 @@ kubernetes
raft raft
`pysyncobj` module in order to use python Raft implementation as DCS `pysyncobj` module in order to use python Raft implementation as DCS
aws aws
`boto` in order to use AWS callbacks `boto3` in order to use AWS callbacks
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is: For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
+10 -9
View File
@@ -16,7 +16,7 @@ networks:
services: services:
etcd1: &etcd etcd1: &etcd
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
environment: environment:
ETCDCTL_API: 3 ETCDCTL_API: 3
@@ -25,6 +25,7 @@ services:
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: tutorial ETCD_INITIAL_CLUSTER_TOKEN: tutorial
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1 container_name: demo-etcd1
hostname: etcd1 hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380 command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
@@ -42,7 +43,7 @@ services:
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380 command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
haproxy: haproxy:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: haproxy hostname: haproxy
@@ -64,7 +65,7 @@ services:
PGSSLROOTCERT: /etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT: /etc/ssl/certs/ssl-cert-snakeoil.pem
coord1: coord1:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: coord1 hostname: coord1
@@ -75,7 +76,7 @@ services:
PATRONI_CITUS_GROUP: 0 PATRONI_CITUS_GROUP: 0
coord2: coord2:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: coord2 hostname: coord2
@@ -85,7 +86,7 @@ services:
PATRONI_NAME: coord2 PATRONI_NAME: coord2
coord3: coord3:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: coord3 hostname: coord3
@@ -96,7 +97,7 @@ services:
work1-1: work1-1:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work1-1 hostname: work1-1
@@ -107,7 +108,7 @@ services:
PATRONI_CITUS_GROUP: 1 PATRONI_CITUS_GROUP: 1
work1-2: work1-2:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work1-2 hostname: work1-2
@@ -118,7 +119,7 @@ services:
work2-1: work2-1:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work2-1 hostname: work2-1
@@ -129,7 +130,7 @@ services:
PATRONI_CITUS_GROUP: 2 PATRONI_CITUS_GROUP: 2
work2-2: work2-2:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work2-2 hostname: work2-2
+6 -5
View File
@@ -14,7 +14,7 @@ networks:
services: services:
etcd1: &etcd etcd1: &etcd
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
environment: environment:
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380 ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
@@ -22,6 +22,7 @@ services:
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: tutorial ETCD_INITIAL_CLUSTER_TOKEN: tutorial
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1 container_name: demo-etcd1
hostname: etcd1 hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380 command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
@@ -39,7 +40,7 @@ services:
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380 command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
haproxy: haproxy:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: haproxy hostname: haproxy
@@ -54,7 +55,7 @@ services:
PATRONI_SCOPE: demo PATRONI_SCOPE: demo
patroni1: patroni1:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: patroni1 hostname: patroni1
@@ -64,7 +65,7 @@ services:
PATRONI_NAME: patroni1 PATRONI_NAME: patroni1
patroni2: patroni2:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: patroni2 hostname: patroni2
@@ -74,7 +75,7 @@ services:
PATRONI_NAME: patroni2 PATRONI_NAME: patroni2
patroni3: patroni3:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: patroni3 hostname: patroni3
BIN
View File
Binary file not shown.
+122 -2
View File
@@ -8,7 +8,7 @@ Wanna contribute to Patroni? Yay - here is how!
Chatting Chatting
-------- --------
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA>`__. Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
Running tests Running tests
------------- -------------
@@ -19,7 +19,7 @@ Requirements for running behave tests:
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`. 2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`. 3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
Install dependencies: Install dependencies:
.. code-block:: bash .. code-block:: bash
@@ -43,6 +43,126 @@ After you have all dependencies installed, you can run the various test suites:
# modify DCS as desired (raft has no dependencies so is the easiest to start with): # modify DCS as desired (raft has no dependencies so is the easiest to start with):
DCS=raft python -m behave DCS=raft python -m behave
Testing with tox
----------------
To run tox tests you only need to install one dependency (other than Python)
.. code-block:: bash
pip install tox>=4
If you wish to run `behave` tests then you also need docker installed.
Tox configuration in `tox.ini` has "environments" to run the following tasks:
* lint: Python code lint with `flake8`
* test: unit tests for all available python interpreters with `pytest`,
generates XML reports or HTML reports if a TTY is detected
* dep: detect package dependency conflicts using `pipdeptree`
* type: static type checking with `pyright`
* black: code formatting with `black`
* docker-build: build docker image used for the `behave` env
* docker-cmd: run arbitrary command with the above image
* docker-behave-etcd: run tox for behave tests with above image
* py*behave: run behave with available python interpreters (without docker, although
this is what is called inside docker containers)
* docs: build docs with `sphinx`
Running tox
^^^^^^^^^^^
To run the default env list; dep, lint, test, and docs, just run:
.. code-block:: bash
tox
The `test` envs can be run with the label `test`:
.. code-block:: bash
tox -m test
The `behave` docker tests can be run with the label `behave`:
.. code-block:: bash
tox -m behave
Similarly, docs has the label `docs`.
All other envs can be run with their respective env names:
.. code-block:: bash
tox -e lint
tox -e py39-test-lin
It is also possible to select partial env lists using `factors`. For example, if you want to run
all envs for python 3.10:
.. code-block:: bash
tox -f py310
This is equivalent to running all the envs listed below:
.. code-block:: bash
$ tox -l -f py310
py310-test-lin
py310-test-mac
py310-test-win
py310-type-lin
py310-type-mac
py310-type-win
py310-behave-etcd-lin
py310-behave-etcd-win
py310-behave-etcd-mac
You can list all configured combinations of environments with tox (>=v4) like so
.. code-block:: bash
tox l
The envs `test` and `docs` will attempt to open the HTML output files
when the job completes, if tox is run with an active terminal. This
is intended to be for benefit of the developer running this env locally.
It will attempt to run `open` on a mac and `xdg-open` on Linux.
To use a different command set the env var `OPEN_CMD` to the name or path of
the command. If this step fails it will not fail the run overall.
If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op command.
.. code-block:: bash
OPEN_CMD=: tox -m docs
Behave tests
^^^^^^^^^^^^
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
Postgres or to a specific feature set or steps.
To specify the version of postgres include the full name of the dependent image build env that you want and then the
behave env name. For instance if you want Postgres 15 use:
.. code-block:: bash
tox -e pg14-docker-build,pg14-docker-behave-etcd-lin
If on the other hand you want to test a specific feature you can pass positional arguments to behave. This will run
the watchdog behave feature test scenario with all versions of Postgres.
.. code-block:: bash
tox -m behave -- features/watchdog.feature
Of course you can combine the two.
Reporting issues Reporting issues
---------------- ----------------
+30 -9
View File
@@ -112,7 +112,11 @@ Kubernetes
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`. - **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`.
- **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates. - **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`. - **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`. - **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **PATRONI\_KUBERNETES\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `master`. Default value is `master`.
- **PATRONI\_KUBERNETES\_FOLLOWER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `replica`. Default value is `replica`.
- **PATRONI\_KUBERNETES\_STANDBY\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is ``standby-leader``. Default value is ``standby-leader``.
- **PATRONI\_KUBERNETES\_TMP\_ROLE\_LABEL**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state. - **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted. - **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set. - **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
@@ -135,7 +139,14 @@ PostgreSQL
- **PATRONI\_POSTGRESQL\_PROXY\_ADDRESS**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery. - **PATRONI\_POSTGRESQL\_PROXY\_ADDRESS**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
- **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni. - **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
- **PATRONI\_POSTGRESQL\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni. - **PATRONI\_POSTGRESQL\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
- **PATRONI\_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. - **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, initdb, pg_controldata, pg_basebackup, postgres, pg_isready, pg_rewind) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_CTL**: (optional) Custom name for ``pg_ctl`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_INITDB**: (optional) Custom name for ``initdb`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_CONTROLDATA**: (optional) Custom name for ``pg_controldata`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_BASEBACKUP**: (optional) Custom name for ``pg_basebackup`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_POSTGRES**: (optional) Custom name for ``postgres`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_IS\_READY**: (optional) Custom name for ``pg_isready`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_REWIND**: (optional) Custom name for ``pg_rewind`` binary.
- **PATRONI\_POSTGRESQL\_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 and under some other circumstances. The location must be writable by Patroni. - **PATRONI\_POSTGRESQL\_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 and under some other circumstances. The location must be writable by Patroni.
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication - **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization. - **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
@@ -159,8 +170,8 @@ PostgreSQL
- **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted. - **PATRONI\_REWIND\_USERNAME**: (optional) name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **PATRONI\_REWIND\_PASSWORD**: password for the user for ``pg_rewind``; the user will be created during initialization. - **PATRONI\_REWIND\_PASSWORD**: (optional) password for the user for ``pg_rewind``; the user will be created during initialization.
- **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``. - **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate. - **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``. - **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``.
@@ -187,11 +198,21 @@ REST API
- **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\_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``.
- **PATRONI\_RESTAPI\_REQUEST\_QUEUE\_SIZE**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
.. warning::
- The ``PATRONI_RESTAPI_CONNECT_ADDRESS`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
- If you enabled client certificates validation (``PATRONI_RESTAPI_VERIFY_CLIENT`` is set to ``required``), you also **must** provide **valid client certificates** in the ``PATRONI_CTL_CERTFILE``, ``PATRONI_CTL_KEYFILE``, ``PATRONI_CTL_KEYFILE_PASSWORD``. If not provided, Patroni will not work correctly.
CTL CTL
--- ---
- **PATRONICTL\_CONFIG\_FILE**: location of the configuration file. - **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
- **PATRONI\_CTL\_INSECURE**: Allow connections to REST API without verifying SSL certs. - **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_CACERT**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter. - **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_CERTFILE**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter. - **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_KEYFILE**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter. - **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format.
- **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format.
- **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile.
+4 -4
View File
@@ -4,7 +4,7 @@
Introduction Introduction
============ ============
Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features. Patroni is a template for high availability (HA) PostgreSQL solutions using Python. Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando. For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
@@ -91,7 +91,7 @@ kubernetes
raft raft
`pysyncobj` module in order to use python Raft implementation as DCS `pysyncobj` module in order to use python Raft implementation as DCS
aws aws
`boto` in order to use AWS callbacks `boto3` in order to use AWS callbacks
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is: For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
@@ -145,7 +145,7 @@ run:
YAML Configuration YAML Configuration
------------------ ------------------
Go :ref:`here <settings>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__. Go :ref:`here <yaml_configuration>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
Environment Configuration Environment Configuration
@@ -179,7 +179,7 @@ That said, here are some pieces of your infrastructure you should be sure to tes
* Network (the network in front of your system as well as the NICs [physical or virtual] themselves) * Network (the network in front of your system as well as the NICs [physical or virtual] themselves)
* Disk IO * Disk IO
* file limits (nofile in Linux) * file limits (nofile in Linux)
* RAM. Even if you have oomkiller turned off as suggested, the unavailability of RAM could cause issues. * RAM. Even if you have oomkiller turned off, the unavailability of RAM could cause issues.
* CPU * CPU
* Virtualization Contention (overcommitting the hypervisor) * Virtualization Contention (overcommitting the hypervisor)
* Any cgroup limitation (likely to be related to the above) * Any cgroup limitation (likely to be related to the above)
-410
View File
@@ -1,410 +0,0 @@
.. _settings:
===========================
YAML Configuration Settings
===========================
.. _dynamic_configuration_settings:
Dynamic configuration settings
------------------------------
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes. Some parameters, like **loop_wait**, **ttl**, **postgresql.parameters.max_connections**, **postgresql.parameters.max_worker_processes** and so on could be set only in the dynamic configuration. Some other parameters like **postgresql.listen**, **postgresql.data_dir** could be set only locally, i.e. in the Patroni config file or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration. In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
- **postgresql**:
- **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
- **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
- **parameters**: list of configuration settings for Postgres.
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
- **host**: an address of remote node
- **port**: a port of remote node
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. 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.
- **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.
- **plugin**: the plugin name for the logical slot.
- **ignore_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
- **name**: the name of the replication slot.
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
- **database**: the database name (when matching a ``logical`` slot).
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
.. code:: YAML
slots:
permanent_logical_slot_name:
type: logical
database: my_db
plugin: test_decoding
permanent_physical_slot_name:
type: physical
...
ignore_slots:
- name: ignored_logical_slot_name
type: logical
database: my_db
plugin: test_decoding
- name: ignored_physical_slot_name
type: physical
...
Global/Universal
----------------
- **name**: the name of the host. Must be unique for the cluster.
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
Log
---
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **loggers**: This section allows redefining logging level per python module
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
.. _bootstrap_settings:
Bootstrap configuration
-----------------------
- **bootstrap**:
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing of new cluster. The global dynamic configuration for the cluster. Under the ``bootstrap.dcs`` you can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration_settings>` and after Patroni initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store. All later changes of ``bootstrap.dcs`` will not take any effect! If you want to change them please use either ``patronictl edit-config`` or Patroni :ref:`REST API <rest_api>`.
- **method**: custom script to use for bootstrapping this cluster.
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
parameter is present in the configuration file.
- **initdb**: List options to be passed on to initdb.
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
- **- host all all 0.0.0.0/0 md5**.
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **users**: Some additional users which need to be created after initializing new cluster
- **admin**: the name of user
- **password: zalando**:
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _citus_settings:
Citus
-----
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
- **group**: the Citus group id, integer. Use ``0`` for coordinator and ``1``, ``2``, etc... for workers
- **database**: the database where ``citus`` extension should be created. Must be the same on the coordinator and all workers. Currently only one database is supported.
.. _consul_settings:
Consul
------
Most of the parameters are optional, but you have to specify one of the **host** or **url**
- **host**: the host:port for the Consul local agent.
- **url**: url for the Consul local agent, in format: http(s)://host:port.
- **port**: (optional) Consul port.
- **scheme**: (optional) **http** or **https**, defaults to **http**.
- **token**: (optional) ACL token.
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**.
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``primary``/``replica``/``standby-leader``). By default an empty list is used.
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
The ``token`` needs to have the following ACL permissions:
::
service_prefix "${scope}" {
policy = "write"
}
key_prefix "${namespace}/${scope}" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
Etcd
----
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
- **host**: the host:port for the etcd endpoint.
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
- **url**: url for the etcd.
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **username**: (optional) username for etcd authentication.
- **password**: (optional) password for etcd authentication.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
Etcdv3
------
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
.. warning::
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
ZooKeeper
----------
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
- **cacert**: (optional) The CA certificate. If present it will enable validation.
- **cert**: (optional) File with the client certificate.
- **key**: (optional) File with the client key.
- **key_password**: (optional) The client key password.
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
Exhibitor
---------
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor.
- **port**: Exhibitor port.
.. _kubernetes_settings:
Kubernetes
----------
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
- **retriable\_http\_codes**: (optional) list of HTTP status codes from K8s API to retry on. By default Patroni is retrying on ``500``, ``503``, and ``504``, or if K8s API response has ``retry-after`` HTTP header.
.. _raft_settings:
Raft (deprecated)
-----------------
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
Short FAQ about Raft implementation
- Q: How to list all the nodes providing consensus?
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
- Q: Where to get the ``syncobj_admin`` utility?
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?
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
.. _postgresql_settings:
PostgreSQL
----------
- **postgresql**:
- **authentication**:
- **superuser**:
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
- **password**: password for the superuser, set during initialization (initdb).
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **replication**:
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
- **password**: replication password; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **rewind**:
- **username**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **password**: password for the user for ``pg_rewind``; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
- **on\_reload**: run this script when configuration reload is triggered.
- **on\_restart**: run this script when the postgres restarts (without changing role).
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
- **on\_start**: run this script when the postgres starts.
- **on\_stop**: run this script when the postgres stops.
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
- **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.
- **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.
- **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.
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. This parameter has higher priority than ``bootstrap.pg_hba``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_hba.conf``.
- **- host all all 0.0.0.0/0 md5**.
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_ident.conf``.
- **- mapname1 systemname1 pguser1**.
- **- mapname1 systemname2 pguser2**.
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
.. _restapi_settings:
REST API
--------
- **restapi**:
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
- **authentication**: (optional)
- **username**: Basic-auth username to protect unsafe REST API endpoints.
- **password**: Basic-auth password to protect unsafe REST API endpoints.
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **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.
- **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.
- **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``.
Here is an example of both **http_extra_headers** and **https_extra_headers**:
.. code:: YAML
restapi:
listen: <listen>
connect_address: <connect_address>
authentication:
username: <username>
password: <password>
http_extra_headers:
'X-Frame-Options': 'SAMEORIGIN'
'X-XSS-Protection': '1; mode=block'
'X-Content-Type-Options': 'nosniff'
cafile: <ca file>
certfile: <cert>
keyfile: <key>
https_extra_headers:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
.. _patronictl_settings:
CTL
---
- **ctl**: (optional)
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
- **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
- **keyfile\_password**: Specifies a password for decrypting the keyfile. If not provided patronictl will use the value provided for REST API "keyfile\_password" parameter.
Watchdog
--------
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
.. _tags_settings:
Tags
----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
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.
+2 -2
View File
@@ -70,7 +70,7 @@ release = __version__
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # Usually you set "language" from the command line for these cases.
language = None language = 'en'
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
@@ -189,7 +189,7 @@ epub_exclude_files = ['search.html']
# Example configuration for intersphinx: refer to the Python standard library. # Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/': None} intersphinx_mapping = {'python': ('https://docs.python.org/', None)}
# A possibility to have an own stylesheet, to add new rules or override existing ones # A possibility to have an own stylesheet, to add new rules or override existing ones
# For the latter case, the CSS specificity of the rules should be higher than the default ones # For the latter case, the CSS specificity of the rules should be higher than the default ones
+2 -2
View File
@@ -23,7 +23,7 @@ In general, it is impossible to distinguish between these two from a single node
DCS Failsafe Mode DCS Failsafe Mode
----------------- -----------------
We introduce a new special option, the ``failsafe_mode``. It could be enabled only via global configuration stored in the DCS ``/config`` key. If the failsafe mode is enabled and the leader lock update in DCS failed due to reasons different from the version/value/index mismatch, Postgres may continue to run as a primary if it can access all known members of the cluster via Patroni REST API. We introduce a new special option, the ``failsafe_mode``. It could be enabled only via global :ref:`dynamic configuration <dynamic_configuration>` stored in the DCS ``/config`` key. If the failsafe mode is enabled and the leader lock update in DCS failed due to reasons different from the version/value/index mismatch, Postgres may continue to run as a primary if it can access all known members of the cluster via Patroni REST API.
Low-level implementation details Low-level implementation details
@@ -53,7 +53,7 @@ F.A.Q.
- What if all members of the Patroni cluster are lost while DCS is down? - What if all members of the Patroni cluster are lost while DCS is down?
Patroni could be configured to create the new replica from the backup even when the cluster doesn't have a leader. But, if the new member isn't present in the ``/failsafe`` key, it will not be able to grab the leader lock and promote. Patroni could be configured to create the new replica from the backup even when the cluster doesn't have a leader. But, if the new member isn't present in the ``/failsafe`` key, it will not be able to grab the leader lock and promote.
- What will happen if the primary lost access to DCS while replicas didn't? - What will happen if the primary lost access to DCS while replicas didn't?
The primary will execute the failsafe code and contact all known replicas. These replicas will use this information as an indicator that the primary is alive and will not start the leader race even if the leader lock in DCS has expired. The primary will execute the failsafe code and contact all known replicas. These replicas will use this information as an indicator that the primary is alive and will not start the leader race even if the leader lock in DCS has expired.
+63 -69
View File
@@ -1,89 +1,83 @@
.. _dynamic_configuration: .. _dynamic_configuration:
Patroni configuration ==============================
===================== Dynamic Configuration Settings
==============================
Patroni configuration is stored in the DCS (Distributed Configuration Store). There are 3 types of configuration: Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
- Dynamic configuration. In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
These options can be set in DCS at any time. If the options changed are not part of the startup configuration,
they are applied asynchronously (upon the next wake up cycle) to every node, which gets subsequently reloaded.
If the node requires a restart to apply the configuration (for options with context postmaster, if their values
have changed), a special flag, ``pending_restart`` indicating this, is set in the members.data JSON.
Additionally, the node status also indicates this, by showing ``"restart_pending": true``.
- Local :ref:`configuration <settings>` (patroni.yml). - **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
These options are defined in the configuration file and take precedence over dynamic configuration. - **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
patroni.yml could be changed and reloaded in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. - **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
- **postgresql**:
- Environment :ref:`configuration <environment>`. - **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
It is possible to set/override some of the "Local" configuration parameters with environment variables. - **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``). - **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
- **parameters**: list of configuration settings for Postgres.
The local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence. - **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. Patroni ignores this parameter if ``hba_file`` PostgreSQL parameter is set to a non-default value.
Some of the PostgreSQL parameters must hold the same values on the primary and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values: - **- host all all 0.0.0.0/0 md5**
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- max_connections: 100 - **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Patroni ignores this parameter if ``ident_file`` PostgreSQL parameter is set to a non-default value.
- max_locks_per_transaction: 64
- max_worker_processes: 8
- max_prepared_transactions: 0
- wal_level: hot_standby
- wal_log_hints: on
- track_commit_timestamp: off
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration - **- mapname1 systemname1 pguser1**
- **- mapname1 systemname2 pguser2**
- max_wal_senders: 5 - **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
- max_replication_slots: 5
- wal_keep_segments: 8
- wal_keep_size: 128MB
These parameters are validated to ensure they are sane, or meet a minimum value. - **host**: an address of remote node
- **port**: a port of remote node
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
- **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
There are some other Postgres parameters controlled by Patroni: - **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable - **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni.
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- cluster_name - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
- hot_standby: on
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__ - **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.
- **plugin**: the plugin name for the logical slot.
- **ignore\_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
When applying the local or dynamic configuration options, the following actions are taken: - **name**: the name of the replication slot.
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
- **database**: the database name (when matching a ``logical`` slot).
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
- The node first checks if there is a postgresql.base.conf or if the ``custom_conf`` parameter is set. Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
- If the `custom_conf` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the `custom_conf` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
- If there is no `custom_conf` nor `postgresql.base.conf`, the original postgresql.conf is taken and renamed to postgresql.base.conf.
- The dynamic options (with the exceptions above) are dumped into the postgresql.conf and an include is set in
postgresql.conf to the used base configuration (either postgresql.base.conf or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
The parameters would be applied in the following order (run-time are given the highest priority): .. code:: YAML
1. load parameters from file `postgresql.base.conf` (or from a `custom_conf` file, if set) slots:
2. load parameters from file `postgresql.conf` permanent_logical_slot_name:
3. load parameters from file `postgresql.auto.conf` type: logical
4. run-time parameter using `-o --name=value` database: my_db
plugin: test_decoding
This allows configuration for all the nodes (2), configuration for a specific node using `ALTER SYSTEM` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1). permanent_physical_slot_name:
type: physical
...
Also, the following Patroni configuration options can be changed only dynamically: ignore_slots:
- name: ignored_logical_slot_name
- ttl: 30 type: logical
- loop_wait: 10 database: my_db
- retry_timeouts: 10 plugin: test_decoding
- maximum_lag_on_failover: 1048576 - name: ignored_physical_slot_name
- max_timelines_history: 0 type: physical
- check_timeline: false ...
- postgresql.use_slots: true
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its
run-time values.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
+56 -16
View File
@@ -10,18 +10,58 @@ To deploy a Patroni cluster without using a pre-existing PostgreSQL instance, se
Procedure Procedure
--------- ---------
A Patroni cluster can be started with a data directory from a single-node PostgreSQL database. This is achieved by following closely these steps: You can find below an overview of steps for converting an existing Postgres cluster to a Patroni managed cluster. In the steps we assume all nodes that are part of the existing cluster are currently up and running, and that you *do not* intend to change Postgres configuration while the migration is ongoing. The steps:
1. Manually start PostgreSQL daemon #. Create the Postgres users as explained for :ref:`authentication <postgresql_settings>` section of the Patroni configuration. You can find sample SQL commands to create the users in the code block below, in which you need to replace the usernames and passwords as per your environment. If you already have the relevant users, then you can skip this step.
2. Create Patroni superuser and replication users as defined in the :ref:`authentication <postgresql_settings>` section of the Patroni configuration. If this user is created in SQL, the following queries achieve this:
.. code-block:: sql .. code-block:: sql
CREATE USER $PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD '$PATRONI_SUPERUSER_PASSWORD'; -- Patroni superuser
CREATE USER $PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD '$PATRONI_REPLICATION_PASSWORD'; -- Replace PATRONI_SUPERUSER_USERNAME and PATRONI_SUPERUSER_PASSWORD accordingly
CREATE USER PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD 'PATRONI_SUPERUSER_PASSWORD';
3. Start Patroni (e.g. ``patroni /etc/patroni/patroni.yml``). It automatically detects that PostgreSQL daemon is already running but its configuration might be out-of-date. -- Patroni replication user
4. Ask Patroni to restart the node with ``patronictl restart cluster-name node-name``. This step is only required if PostgreSQL configuration is out-of-date. -- Replace PATRONI_REPLICATION_USERNAME and PATRONI_REPLICATION_PASSWORD accordingly
CREATE USER PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD 'PATRONI_REPLICATION_PASSWORD';
-- Patroni rewind user, if you intend to enable use_pg_rewind in your Patroni configuration
-- Replace PATRONI_REWIND_USERNAME and PATRONI_REWIND_PASSWORD accordingly
CREATE USER PATRONI_REWIND_USERNAME WITH ENCRYPTED PASSWORD 'PATRONI_REWIND_PASSWORD';
GRANT EXECUTE ON function pg_catalog.pg_ls_dir(text, boolean, boolean) TO PATRONI_REWIND_USERNAME;
GRANT EXECUTE ON function pg_catalog.pg_stat_file(text, boolean) TO PATRONI_REWIND_USERNAME;
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text) TO PATRONI_REWIND_USERNAME;
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text, bigint, bigint, boolean) TO PATRONI_REWIND_USERNAME;
#. Perform the following steps on all Postgres nodes. Perform all steps on one node before proceeding with the next node. Start with the primary node, then proceed with each standby node:
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
#. Create a YAML configuration file for Patroni.
* **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings <yaml_configuration>` for details.
#. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance.
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through ``patronictl restart cluster-name member-name`` command. For minimal downtime you might want to split this step into:
#. Immediate restart of the standby nodes.
#. Scheduled restart of the primary node within a maintenance window.
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through ``patronictl edit-config cluster-name member-name`` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
.. code-block:: sql
-- Assume original_slot_for_member_x is the name of the slot in your original
-- cluster for replicating changes to member X, and slot_for_member_x is the
-- slot created by Patroni for that purpose. You need restart_lsn of
-- slot_for_member_x to be >= restart_lsn of original_slot_for_member_x
SELECT slot_name,
restart_lsn
FROM pg_replication_slots
WHERE slot_name IN (
'original_slot_for_member_x',
'slot_for_member_x'
)
.. _major_upgrade: .. _major_upgrade:
@@ -30,14 +70,14 @@ Major Upgrade of PostgreSQL Version
The only possible way to do a major upgrade currently is: The only possible way to do a major upgrade currently is:
1. Stop Patroni #. Stop Patroni
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node #. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
3. Update patroni.yml #. Update patroni.yml
4. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier. #. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
5. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before. #. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
6. Start Patroni on the primary node. #. Start Patroni on the primary node.
7. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes. #. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
8. Start Patroni on the standby nodes and wait for the replication to complete. #. Start Patroni on the standby nodes and wait for the replication to complete.
Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you. Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you.
+4 -4
View File
@@ -1,8 +1,8 @@
.. _ha_multi_dc: .. _ha_multi_dc:
================= ===================
HA multi datacenter HA multi datacenter
================= ===================
The high availability of a PostgreSQL cluster deployed in multiple data centers is based on replication, which can be synchronous or asynchronous (`replication_modes <replication_modes.rst>`_). The high availability of a PostgreSQL cluster deployed in multiple data centers is based on replication, which can be synchronous or asynchronous (`replication_modes <replication_modes.rst>`_).
@@ -22,7 +22,7 @@ The architecture diagram would be the following:
We must deploy a cluster of etcd, ZooKeeper or Consul through the different DC, with a minimum of 3 nodes, one in each zone. We must deploy a cluster of etcd, ZooKeeper or Consul through the different DC, with a minimum of 3 nodes, one in each zone.
Regarding postgres, we must deploy at least 2 nodes, in different DC. Then you have to set ``synchronous_mode: true`` in the global configuration (``patronictl edit-config``). Regarding postgres, we must deploy at least 2 nodes, in different DC. Then you have to set ``synchronous_mode: true`` in the global :ref:`dynamic configuration <dynamic_configuration>`.
This enables sync replication and the primary node will choose one of the nodes as synchronous. This enables sync replication and the primary node will choose one of the nodes as synchronous.
@@ -37,7 +37,7 @@ The architecture diagram would be the following:
Automatic promotion is not possible, because DC2 will never able to figure out the state of DC1. Automatic promotion is not possible, because DC2 will never able to figure out the state of DC1.
You should not use ``pg_ctl promote`` in this scenario, you need "manually promote" the healthy cluster with ``patronictl edit-config`` and remove ``standby_cluster`` section from there. You should not use ``pg_ctl promote`` in this scenario, you need "manually promote" the healthy cluster by removing ``standby_cluster`` section from the :ref:`dynamic configuration <dynamic_configuration>`.
.. warning:: .. warning::
If the source cluster is still up and running and you promote the standby cluster you create a split-brain. If the source cluster is still up and running and you promote the standby cluster you create a split-brain.
+9 -11
View File
@@ -6,7 +6,7 @@
Introduction Introduction
============ ============
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful. Patroni is a template for high availability (HA) PostgreSQL solutions using Python. For maximum accessibility, Patroni supports a variety of distributed configuration stores like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in datacenters — or anywhere elsewill hopefully find it useful.
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__. We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
@@ -22,20 +22,18 @@ Currently supported PostgreSQL versions: 9.3 to 15.
:caption: Contents: :caption: Contents:
README README
citus patroni_configuration
dynamic_configuration
dcs_failsafe_mode
rest_api rest_api
existing_data
ENVIRONMENT
SETTINGS
security
replica_bootstrap replica_bootstrap
replication_modes replication_modes
ha_multi_dc
pause
kubernetes
watchdog watchdog
pause
dcs_failsafe_mode
kubernetes
citus
existing_data
security
ha_multi_dc
releases releases
CONTRIBUTING CONTRIBUTING
+52
View File
@@ -32,6 +32,58 @@ Configuration
Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation. Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation.
.. _kubernetes_role_values:
Customize role label
^^^^^^^^^^^^^^^^^^^^
By default, Patroni will set corresponding labels on the pod it runs in based on node's role, such as ``role=master``.
The key and value of label can be customized by `kubernetes.role_label`, `kubernetes.leader_label_value`, `kubernetes.follower_label_value` and `kubernetes.standby_leader_label_value`.
Note that if you migrate from default role labels to custom ones, you can reduce downtime by following migration steps:
1. Add a temporary label using original role value for the pod with `kubernetes.tmp_role_label` (like ``tmp_role``). Once pods are restarted they will get following labels set by Patroni:
.. code:: YAML
labels:
cluster-name: foo
role: master
tmp_role: master
2. After all pods have been updated, modify the service selector to select the temporary label.
.. code:: YAML
selector:
cluster-name: foo
tmp_role: master
3. Add your custom role label (e.g., set `kubernetes.leader_label_value=primary`). Once pods are restarted they will get following new labels set by Patroni:
.. code:: YAML
labels:
cluster-name: foo
role: primary
tmp_role: master
4. After all pods have been updated again, modify the service selector to use new role value.
.. code:: YAML
selector:
cluster-name: foo
role: primary
5. Finally, remove the temporary label from your configuration and update all pods.
.. code:: YAML
labels:
cluster-name: foo
role: primary
Examples Examples
-------- --------
+108
View File
@@ -0,0 +1,108 @@
.. _patroni_configuration:
Patroni configuration
=====================
.. toctree::
:hidden:
dynamic_configuration
yaml_configuration
ENVIRONMENT
There are 3 types of Patroni configuration:
- Global :ref:`dynamic configuration <dynamic_configuration>`.
These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
Dynamic configuration can be set at any time using ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
If the options changed are not part of the startup configuration, they are applied asynchronously (upon the next wake up cycle)
to every node, which gets subsequently reloaded.
If the node requires a restart to apply the configuration (for `PostgreSQL parameters <https://www.postgresql.org/docs/current/view-pg-settings.html>`__ with context postmaster, if their values
have changed), a special flag ``pending_restart`` indicating this is set in the members.data JSON.
Additionally, the node status indicates this by showing ``"restart_pending": true``.
- Local :ref:`configuration file <yaml_configuration>` (patroni.yml).
These options are defined in the configuration file and take precedence over dynamic configuration.
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
- :ref:`Environment configuration <environment>`.
It is possible to set/override some of the "Local" configuration parameters with environment variables.
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
Important rules
---------------
PostgreSQL parameters controlled by Patroni
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Some of the PostgreSQL parameters **must hold the same values on the primary and the replicas**. For those, **values set either in the local patroni configuration files or via the environment variables take no effect**. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
- **max_connections**: 100
- **max_locks_per_transaction**: 64
- **max_worker_processes**: 8
- **max_prepared_transactions**: 0
- **wal_level**: hot_standby
- **wal_log_hints**: on
- **track_commit_timestamp**: off
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, **Patroni restricts setting their values to the** :ref:`dynamic configuration <dynamic_configuration>`.
- **max_wal_senders**: 5
- **max_replication_slots**: 5
- **wal_keep_segments**: 8
- **wal_keep_size**: 128MB
These parameters are validated to ensure they are sane, or meet a minimum value.
There are some other Postgres parameters controlled by Patroni:
- **listen_addresses** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- **port** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- **cluster_name** - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
- **hot_standby: on**
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
There also are some parameters like **postgresql.listen**, **postgresql.data_dir** that **can be set only locally**, i.e. in the Patroni :ref:`config file <yaml_configuration>` or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration.
When applying the local or dynamic configuration options, the following actions are taken:
- The node first checks if there is a `postgresql.base.conf` or if the ``custom_conf`` parameter is set.
- If the ``custom_conf`` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
- If there is no ``custom_conf``` nor `postgresql.base.conf`, the original `postgresql.conf`` is taken and renamed to postgresql.base.conf.
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf`` and an include is set in
postgresql.conf to the used base configuration (either `postgresql.base.conf` or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
The parameters would be applied in the following order (run-time are given the highest priority):
1. load parameters from file `postgresql.base.conf` (or from a ``custom_conf`` file, if set)
2. load parameters from file `postgresql.conf`
3. load parameters from file `postgresql.auto.conf`
4. run-time parameter using `-o --name=value`
This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
Patroni configuration parameters
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Also the following Patroni configuration options **can be changed only dynamically**:
- **ttl**: 30
- **loop_wait**: 10
- **retry_timeouts**: 10
- **maximum_lag_on_failover**: 1048576
- **max_timelines_history**: 0
- **check_timeline**: false
- **postgresql.use_slots**: true
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
+476 -300
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -49,7 +49,7 @@ on at least two nodes, enable ``synchronous_mode_strict`` in addition to the
``synchronous_mode``. This parameter prevents Patroni from switching off the ``synchronous_mode``. This parameter prevents Patroni from switching off the
synchronous replication on the primary when no synchronous standby candidates synchronous replication on the primary when no synchronous standby candidates
are available. As a downside, the primary is not be available for writes are available. As a downside, the primary is not be available for writes
(unless the Postgres transaction explicitly turns of ``synchronous_mode``), (unless the Postgres transaction explicitly turns off ``synchronous_mode``),
blocking all client write requests until at least one synchronous replica comes blocking all client write requests until at least one synchronous replica comes
up. up.
+10 -3
View File
@@ -30,7 +30,8 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``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 /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.
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. 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 /?tag_key1=value1&tag_key2=value2``
- ``GET /leader?tag_key1=value1&tag_key2=value2`` - ``GET /leader?tag_key1=value1&tag_key2=value2``
- ``GET /primary?tag_key1=value1&tag_key2=value2`` - ``GET /primary?tag_key1=value1&tag_key2=value2``
@@ -140,8 +141,8 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
# TYPE patroni_replica gauge # TYPE patroni_replica gauge
patroni_replica{scope="batman"} 0 patroni_replica{scope="batman"} 0
# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise. # HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.
# TYPE patroni_sync_standby gauge # TYPE patroni_sync_standby gauge
patroni_sync_standby{scope="batman"} 0 patroni_sync_standby{scope="batman"} 0
# HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica. # HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica.
# TYPE patroni_xlog_received_location counter # TYPE patroni_xlog_received_location counter
patroni_xlog_received_location{scope="batman"} 0 patroni_xlog_received_location{scope="batman"} 0
@@ -154,6 +155,12 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise. # HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.
# TYPE patroni_xlog_paused gauge # TYPE patroni_xlog_paused gauge
patroni_xlog_paused{scope="batman"} 0 patroni_xlog_paused{scope="batman"} 0
# HELP patroni_postgres_streaming Value is 1 if Postgres is streaming, 0 otherwise.
# TYPE patroni_postgres_streaming gauge
patroni_postgres_streaming{scope="batman"} 1
# HELP patroni_postgres_in_archive_recovery Value is 1 if Postgres is replicating from archive, 0 otherwise.
# TYPE patroni_postgres_in_archive_recovery gauge
patroni_postgres_in_archive_recovery{scope="batman"} 0
# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise. # HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.
# TYPE patroni_postgres_server_version gauge # TYPE patroni_postgres_server_version gauge
patroni_postgres_server_version {scope="batman"} 140004 patroni_postgres_server_version {scope="batman"} 140004
+2 -2
View File
@@ -13,7 +13,7 @@ Patroni and patronictl both store and retrieve data to/from the DCS.
Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself. Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`SETTINGS <bootstrap_settings>` The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`settings <yaml_configuration>`.
The general recommendation is to enable TLS for all DCS communication. The general recommendation is to enable TLS for all DCS communication.
@@ -32,6 +32,6 @@ When TLS for the REST API is enabled and a PKI is established, mutual authentica
The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``). The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``).
The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`SETTINGS <patronictl_settings>` for a detailed description of the TLS client parameters. The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html
+387
View File
@@ -0,0 +1,387 @@
.. _yaml_configuration:
============================
YAML Configuration Settings
============================
Global/Universal
----------------
- **name**: the name of the host. Must be unique for the cluster.
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
Log
---
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **loggers**: This section allows redefining logging level per python module
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
.. _bootstrap_settings:
Bootstrap configuration
-----------------------
- **bootstrap**:
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing of new cluster. The global dynamic configuration for the cluster. Under the ``bootstrap.dcs`` you can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration>` and after Patroni initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store. All later changes of ``bootstrap.dcs`` will not take any effect! If you want to change them please use either ``patronictl edit-config`` or Patroni :ref:`REST API <rest_api>`.
- **method**: custom script to use for bootstrapping this cluster.
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
parameter is present in the configuration file.
- **initdb**: (optional) list options to be passed on to initdb.
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **users**: Some additional users which need to be created after initializing new cluster
- **admin**: the name of user
- **password**: (optional) password for the user
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _citus_settings:
Citus
-----
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
- **group**: the Citus group id, integer. Use ``0`` for coordinator and ``1``, ``2``, etc... for workers
- **database**: the database where ``citus`` extension should be created. Must be the same on the coordinator and all workers. Currently only one database is supported.
.. _consul_settings:
Consul
------
Most of the parameters are optional, but you have to specify one of the **host** or **url**
- **host**: the host:port for the Consul local agent.
- **url**: url for the Consul local agent, in format: http(s)://host:port.
- **port**: (optional) Consul port.
- **scheme**: (optional) **http** or **https**, defaults to **http**.
- **token**: (optional) ACL token.
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**.
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``primary``/``replica``/``standby-leader``). By default an empty list is used.
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
The ``token`` needs to have the following ACL permissions:
::
service_prefix "${scope}" {
policy = "write"
}
key_prefix "${namespace}/${scope}" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
Etcd
----
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
- **host**: the host:port for the etcd endpoint.
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
- **url**: url for the etcd.
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **username**: (optional) username for etcd authentication.
- **password**: (optional) password for etcd authentication.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
Etcdv3
------
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
.. warning::
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
ZooKeeper
----------
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
- **cacert**: (optional) The CA certificate. If present it will enable validation.
- **cert**: (optional) File with the client certificate.
- **key**: (optional) File with the client key.
- **key_password**: (optional) The client key password.
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
Exhibitor
---------
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor.
- **port**: Exhibitor port.
.. _kubernetes_settings:
Kubernetes
----------
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **role\_label**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **leader\_label\_value**: (optional) value of the pod label when Postgres role is ``master``. Default value is ``master``.
- **follower\_label\_value**: (optional) value of the pod label when Postgres role is ``replica``. Default value is ``replica``.
- **standby\_leader\_label\_value**: (optional) value of the pod label when Postgres role is ``standby-leader``. Default value is ``standby-leader``.
- **tmp_\role\_label**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
- **retriable\_http\_codes**: (optional) list of HTTP status codes from K8s API to retry on. By default Patroni is retrying on ``500``, ``503``, and ``504``, or if K8s API response has ``retry-after`` HTTP header.
.. _raft_settings:
Raft (deprecated)
-----------------
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
Short FAQ about Raft implementation
- Q: How to list all the nodes providing consensus?
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
- Q: Where to get the ``syncobj_admin`` utility?
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?
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
.. _postgresql_settings:
PostgreSQL
----------
- **postgresql**:
- **authentication**:
- **superuser**:
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
- **password**: password for the superuser, set during initialization (initdb).
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **replication**:
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
- **password**: replication password; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **rewind**:
- **username**: (optional) name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **password**: (optional) password for the user for ``pg_rewind``; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
- **on\_reload**: run this script when configuration reload is triggered.
- **on\_restart**: run this script when the postgres restarts (without changing role).
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
- **on\_start**: run this script when the postgres starts.
- **on\_stop**: run this script when the postgres stops.
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
- **bin\_dir**: (optional) Path to PostgreSQL binaries (pg_ctl, initdb, pg_controldata, pg_basebackup, postgres, pg_isready, pg_rewind). If not provided or is an empty string, PATH environment variable will be used to find the executables.
- **bin\_name**: (optional) Make it possible to override Postgres binary names, if you are using a custom Postgres distribution:
- **pg\_ctl**: (optional) Custom name for ``pg_ctl`` binary.
- **initdb**: (optional) Custom name for ``initdb`` binary.
- **pg\controldata**: (optional) Custom name for ``pg_controldata`` binary.
- **pg\_basebackup**: (optional) Custom name for ``pg_basebackup`` binary.
- **postgres**: (optional) Custom name for ``postgres`` binary.
- **pg\_isready**: (optional) Custom name for ``pg_isready`` binary.
- **pg\_rewind**: (optional) Custom name for ``pg_rewind`` binary.
- **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\_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.
- **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 <patroni_configuration>` for details.
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. Patroni ignores this parameter if ``hba_file`` PostgreSQL parameter is set to a non-default value. Together with :ref:`dynamic configuration <dynamic_configuration>` this parameter simplifies management of ``pg_hba.conf``.
- **- host all all 0.0.0.0/0 md5**
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Patroni ignores this parameter if ``ident_file`` PostgreSQL parameter is set to a non-default value. Together with :ref:`dynamic configuration <dynamic_configuration>` this parameter simplifies management of ``pg_ident.conf``.
- **- mapname1 systemname1 pguser1**
- **- mapname1 systemname2 pguser2**
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
- **before\_stop**: a script that executes immediately prior to stopping postgres. As opposed to a callback, this script runs synchronously, blocking shutdown until it has completed. The return code of this script does not impact whether shutdown proceeds afterwards.
.. _restapi_settings:
REST API
--------
- **restapi**:
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
- **authentication**: (optional)
- **username**: Basic-auth username to protect unsafe REST API endpoints.
- **password**: Basic-auth password to protect unsafe REST API endpoints.
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **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.
- **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.
- **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``.
- **request_queue_size**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
Here is an example of both **http_extra_headers** and **https_extra_headers**:
.. code:: YAML
restapi:
listen: <listen>
connect_address: <connect_address>
authentication:
username: <username>
password: <password>
http_extra_headers:
'X-Frame-Options': 'SAMEORIGIN'
'X-XSS-Protection': '1; mode=block'
'X-Content-Type-Options': 'nosniff'
cafile: <ca file>
certfile: <cert>
keyfile: <key>
https_extra_headers:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
.. warning::
- The ``restapi.connect_address`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
- If you enabled client certificates validation (``restapi.verify_client`` is set to ``required``), you also **must** provide **valid client certificates** in the ``ctl.certfile``, ``ctl.keyfile``, ``ctl.keyfile_password``. If not provided, Patroni will not work correctly.
.. _patronictl_settings:
CTL
---
- **ctl**: (optional)
- **authentication**:
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format.
- **keyfile**: Specifies the file with the client secret key in the PEM format.
- **keyfile\_password**: Specifies a password for decrypting the client keyfile.
Watchdog
--------
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
.. _tags_settings:
Tags
----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
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.
+94
View File
@@ -0,0 +1,94 @@
# syntax = docker/dockerfile:1.5
# Used only for running tests using tox, see ../tox.ini
ARG PG_MAJOR
ARG PGHOME=/home/postgres
ARG LC_ALL=C.UTF-8
ARG LANG=C.UTF-8
ARG BASE_IMAGE=postgres
FROM ${BASE_IMAGE}:${PG_MAJOR}
ARG PGHOME
ARG LC_ALL
ARG LANG
ENV PGHOME="$PGHOME"
ENV PG_USER="${PG_USER:-postgres}"
ENV PG_GROUP="${PG_GROUP:-$PG_USER}"
ENV LC_ALL="$LC_ALL"
ENV LANG="$LANG"
ARG ETCDVERSION=3.3.13
ENV ETCDVERSION="$ETCDVERSION"
ARG ETCDURL="https://github.com/coreos/etcd/releases/download/v$ETCDVERSION"
USER root
RUN set -ex \
&& apt-get update \
&& apt-get reinstall init-system-helpers \
&& apt-get install -y \
python3-pip \
python3-dev \
rsync \
curl \
gcc \
golang \
jq \
locales \
sudo \
busybox \
net-tools \
iputils-ping \
&& rm -rf /var/cache/apt \
&& python3 -m pip install --no-cache-dir tox \
\
&& mkdir -p "$PGHOME" \
&& sed -i "s|/var/lib/postgresql.*|$PGHOME:/bin/bash|" /etc/passwd \
&& chown -R "$PG_USER:$PG_GROUP" /var/log /home/postgres \
\
# Download etcd \
&& curl -sL "$ETCDURL/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
# This Dockerfile syntax only works with docker buildx and the syntax
# line at the top of this file.
COPY <<EOF /tox-wrapper.sh
#!/usr/bin/env bash
set -ex
copy_output() {
if [[ -d "\$PGHOME/src/features/output" && /src/features ]] ;then
cp -a "\$PGHOME/src/features/output" "/src/features/output-\$HOSTNAME"
find "/src/features/output-\$HOSTNAME" -type f -exec chmod 666 {} \\;
find "/src/features/output-\$HOSTNAME" -type d -exec chmod 777 {} \\;
fi
}
# Ensure the copy is ran if the container is stopped with `docker stop` or `docker kill`
trap 'copy_output' SIGTERM
# For architectures such as aarch we need to get the respective GOARCH
# so we can tell etcd we're ok with running an unsupported architecture.
export ETCD_UNSUPPORTED_ARCH=$(go env GOARCH)
cd /src
runuser -u "\$PG_USER" -- \\
find . ! -readable 2>/dev/null \\
| sed 's|^./||' >/tmp/copy_exclude.lst \\
|| true
runuser -u "\$PG_USER" -- \\
rsync -a \\
--exclude=.tox \\
--exclude="features/output*" \\
--exclude-from="/tmp/copy_exclude.lst" \\
. "\$PGHOME/src/"
cd "\$PGHOME/src"
runuser -u "\$PG_USER" -w ETCD_UNSUPPORTED_ARCH -- "\$@" &
wait $!
# SIGINT whilst child proc is running is not seen by trap so we run a copy here instead of using
# trap copy_output SIGINT EXIT
copy_output
EOF
RUN chmod +x /tox-wrapper.sh
VOLUME /src
ENTRYPOINT ["/tox-wrapper.sh"]
+6 -6
View File
@@ -72,14 +72,14 @@ Feature: basic replication
Then table bar is present on postgres1 after 20 seconds Then table bar is present on postgres1 after 20 seconds
And Response on GET http://127.0.0.1:8010/config contains master_start_timeout after 10 seconds And Response on GET http://127.0.0.1:8010/config contains master_start_timeout after 10 seconds
Scenario: check immediate failover when master_start_timeout=0
Given I kill postmaster on postgres2
Then postgres1 is a leader after 10 seconds
And postgres1 role is the primary after 10 seconds
Scenario: check rejoin of the former primary with pg_rewind Scenario: check rejoin of the former primary with pg_rewind
Given I add the table splitbrain to postgres0 Given I add the table splitbrain to postgres0
And I start postgres0 And I start postgres0
Then postgres0 role is the secondary after 20 seconds Then postgres0 role is the secondary after 20 seconds
When I add the table buz to postgres1 When I add the table buz to postgres2
Then table buz is present on postgres0 after 20 seconds Then table buz is present on postgres0 after 20 seconds
@reject-duplicate-name
Scenario: check graceful rejection when two nodes have the same name
Given I start duplicate postgres0 on port 8011
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
+11 -10
View File
@@ -10,20 +10,21 @@ Feature: citus
And I start postgres3 in citus group 1 And I start postgres3 in citus group 1
Then replication works from postgres0 to postgres1 after 15 seconds Then replication works from postgres0 to postgres1 after 15 seconds
Then replication works from postgres2 to postgres3 after 15 seconds Then replication works from postgres2 to postgres3 after 15 seconds
And postgres0 is registered in the postgres0 as the worker in group 0 And postgres0 is registered in the postgres0 as the primary in group 0 after 5 seconds
And postgres2 is registered in the postgres0 as the worker in group 1 And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
Scenario: coordinator failover updates pg_dist_node Scenario: coordinator failover updates pg_dist_node
Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force
Then postgres1 role is the primary after 10 seconds Then postgres1 role is the primary after 10 seconds
And "members/postgres0" key in a group 0 in DCS has state=running after 15 seconds
And replication works from postgres1 to postgres0 after 15 seconds And replication works from postgres1 to postgres0 after 15 seconds
And postgres1 is registered in the postgres2 as the primary in group 0 after 5 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds
And postgres1 is registered in the postgres2 as the worker in group 0 When I run patronictl.py switchover batman --group 0 --candidate postgres0 --force
When I run patronictl.py failover batman --group 0 --candidate postgres0 --force
Then postgres0 role is the primary after 10 seconds Then postgres0 role is the primary after 10 seconds
And replication works from postgres0 to postgres1 after 15 seconds And replication works from postgres0 to postgres1 after 15 seconds
And postgres0 is registered in the postgres2 as the primary in group 0 after 5 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds
And postgres0 is registered in the postgres2 as the worker in group 0
Scenario: worker switchover doesn't break client queries on the coordinator Scenario: worker switchover doesn't break client queries on the coordinator
Given I create a distributed table on postgres0 Given I create a distributed table on postgres0
@@ -31,16 +32,17 @@ Feature: citus
When I run patronictl.py switchover batman --group 1 --force When I run patronictl.py switchover batman --group 1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres3 role is the primary after 10 seconds And postgres3 role is the primary after 10 seconds
And "members/postgres2" key in a group 1 in DCS has state=running after 15 seconds
And replication works from postgres3 to postgres2 after 15 seconds And replication works from postgres3 to postgres2 after 15 seconds
And postgres3 is registered in the postgres0 as the primary in group 1 after 5 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds
And postgres3 is registered in the postgres0 as the worker in group 1
And a thread is still alive And a thread is still alive
When I run patronictl.py switchover batman --group 1 --force When I run patronictl.py switchover batman --group 1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres2 role is the primary after 10 seconds And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the worker in group 1
And a thread is still alive And a thread is still alive
When I stop a thread When I stop a thread
Then a distributed table on postgres0 has expected rows Then a distributed table on postgres0 has expected rows
@@ -52,7 +54,7 @@ Feature: citus
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres2 role is the primary after 10 seconds And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the worker in group 1 And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And a thread is still alive And a thread is still alive
When I stop a thread When I stop a thread
Then a distributed table on postgres0 has expected rows Then a distributed table on postgres0 has expected rows
@@ -64,8 +66,7 @@ Feature: citus
When I run patronictl.py edit-config batman --group 2 -s ttl=20 --force When I run patronictl.py edit-config batman --group 2 -s ttl=20 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And I receive a response output "+ttl: 20" And I receive a response output "+ttl: 20"
When I sleep for 2 seconds Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds
Then postgres4 is registered in the postgres2 as the worker in group 2
When I shut down postgres4 When I shut down postgres4
Then There is a transaction in progress on postgres0 changing pg_dist_node Then There is a transaction in progress on postgres0 changing pg_dist_node
When I run patronictl.py restart batman postgres2 --group 1 --force When I run patronictl.py restart batman postgres2 --group 1 --force
+39 -26
View File
@@ -7,6 +7,7 @@ import psutil
import re import re
import shutil import shutil
import signal import signal
import stat
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -51,15 +52,14 @@ class AbstractController(abc.ABC):
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a') self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
self._handle = self._start() self._handle = self._start()
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
max_wait_limit *= self._context.timeout_multiplier max_wait_limit *= self._context.timeout_multiplier
for _ in range(max_wait_limit): for _ in range(max_wait_limit):
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
if self._is_accessible(): if self._is_accessible():
break break
time.sleep(1) time.sleep(1)
else: else:
assert False,\ assert False, \
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit) "{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
def stop(self, kill=False, timeout=15, _=False): def stop(self, kill=False, timeout=15, _=False):
@@ -191,7 +191,7 @@ class PatroniController(AbstractController):
config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']} config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']}
host = config['restapi']['listen'].rsplit(':', 1)[0] host = config['restapi']['listen'].rsplit(':', 1)[0]
config['restapi']['listen'] = config['restapi']['connect_address'] = '{0}:{1}'.format(host, 8008+int(name[-1])) config['restapi']['listen'] = config['restapi']['connect_address'] = '{}:{}'.format(host, 8008 + int(name[-1]))
host = config['postgresql']['listen'].rsplit(':', 1)[0] host = config['postgresql']['listen'].rsplit(':', 1)[0]
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT) config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
@@ -251,9 +251,9 @@ class PatroniController(AbstractController):
'parameters': { 'parameters': {
'wal_keep_segments': 100, 'wal_keep_segments': 100,
'archive_mode': 'on', 'archive_mode': 'on',
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT + 'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
' --mode archive ' + + ' --mode archive '
'--dirname {} --filename %f --pathname %p').format( + '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data', 'wal_archive')) os.path.join(self._work_directory, 'data', 'wal_archive'))
} }
} }
@@ -343,6 +343,13 @@ class PatroniController(AbstractController):
'--datadir=' + os.path.join(self._work_directory, dest), '--datadir=' + os.path.join(self._work_directory, dest),
'--dbname=' + self.backup_source]) '--dbname=' + self.backup_source])
def read_patroni_log(self, level):
try:
with open(str(os.path.join(self._output_dir or '', self._name + ".log"))) as f:
return [line for line in f.readlines() if line[24:24 + len(level)] == level]
except IOError:
return []
class ProcessHang(object): class ProcessHang(object):
@@ -594,7 +601,7 @@ class KubernetesController(AbstractExternalDcsController):
api_process = 'kube-apiserver' api_process = 'kube-apiserver'
elif context.startswith('k3d-'): elif context.startswith('k3d-'):
container = '{0}-server-0'.format(context) container = '{0}-server-0'.format(context)
api_process = 'k3s' api_process = 'k3s server'
else: else:
return super(KubernetesController, self)._is_running() return super(KubernetesController, self)._is_running()
try: try:
@@ -801,7 +808,7 @@ class PatroniPoolController(object):
raise Exception # this one should never happen because the previous line will always raise and exception raise Exception # this one should never happen because the previous line will always raise and exception
except Exception as e: except Exception as e:
self._context.postgres_supports_ssl = isinstance(e, subprocess.CalledProcessError)\ self._context.postgres_supports_ssl = isinstance(e, subprocess.CalledProcessError)\
and 'SSL is not supported by this build' not in e.output.decode() and 'SSL is not supported by this build' not in e.output.decode()
@property @property
def patroni_path(self): def patroni_path(self):
@@ -826,7 +833,7 @@ class PatroniPoolController(object):
def __getattr__(self, func): def __getattr__(self, func):
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to', if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to',
'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup']: 'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup', 'read_patroni_log']:
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func)) raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
def wrapper(name, *args, **kwargs): def wrapper(name, *args, **kwargs):
@@ -853,8 +860,8 @@ class PatroniPoolController(object):
'bootstrap': { 'bootstrap': {
'method': 'pg_basebackup', 'method': 'pg_basebackup',
'pg_basebackup': { 'pg_basebackup': {
'command': " ".join(self.BACKUP_SCRIPT + 'command': " ".join(self.BACKUP_SCRIPT
['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)]) + ['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)])
}, },
'dcs': { 'dcs': {
'postgresql': { 'postgresql': {
@@ -867,9 +874,9 @@ class PatroniPoolController(object):
'postgresql': { 'postgresql': {
'parameters': { 'parameters': {
'archive_mode': 'on', 'archive_mode': 'on',
'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive ' + 'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive '
'--dirname {} --filename %f --pathname %p').format( + '--dirname {} --filename %f --pathname %p')
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/')) .format(os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
}, },
'authentication': { 'authentication': {
'superuser': {'password': 'zalando1'}, 'superuser': {'password': 'zalando1'},
@@ -885,13 +892,13 @@ class PatroniPoolController(object):
'bootstrap': { 'bootstrap': {
'method': 'backup_restore', 'method': 'backup_restore',
'backup_restore': { 'backup_restore': {
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' + 'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')), + os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'recovery_conf': { 'recovery_conf': {
'recovery_target_action': 'promote', 'recovery_target_action': 'promote',
'recovery_target_timeline': 'latest', 'recovery_target_timeline': 'latest',
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' + 'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
'--dirname {} --filename %f --pathname %p').format( + '--dirname {} --filename %f --pathname %p').format(
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/')) os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
} }
} }
@@ -910,14 +917,14 @@ class PatroniPoolController(object):
'scope': cluster_name, 'scope': cluster_name,
'postgresql': { 'postgresql': {
'recovery_conf': { 'recovery_conf': {
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' + 'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
'--dirname {} --filename %f --pathname %p').format( + '--dirname {} --filename %f --pathname %p')
os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/')) .format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
}, },
'create_replica_methods': ['no_leader_bootstrap'], 'create_replica_methods': ['no_leader_bootstrap'],
'no_leader_bootstrap': { 'no_leader_bootstrap': {
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' + 'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')), + os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'no_leader': '1' 'no_leader': '1'
} }
} }
@@ -1060,9 +1067,11 @@ def before_all(context):
try: try:
with open(os.devnull, 'w') as null: with open(os.devnull, 'w') as null:
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni', ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
'-keyout', context.keyfile, '-out', context.certfile], stdout=null, stderr=null) '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', context.keyfile,
'-out', context.certfile], stdout=null, stderr=null)
if ret != 0: if ret != 0:
raise Exception raise Exception
os.chmod(context.keyfile, stat.S_IWRITE | stat.S_IREAD)
except Exception: except Exception:
context.keyfile = context.certfile = None context.keyfile = context.certfile = None
@@ -1073,7 +1082,9 @@ def before_all(context):
'PATRONI_RESTAPI_CERTFILE': context.certfile, 'PATRONI_RESTAPI_CERTFILE': context.certfile,
'PATRONI_RESTAPI_KEYFILE': context.keyfile, 'PATRONI_RESTAPI_KEYFILE': context.keyfile,
'PATRONI_RESTAPI_VERIFY_CLIENT': 'required', 'PATRONI_RESTAPI_VERIFY_CLIENT': 'required',
'PATRONI_CTL_INSECURE': 'on'}) 'PATRONI_CTL_INSECURE': 'on',
'PATRONI_CTL_CERTFILE': context.certfile,
'PATRONI_CTL_KEYFILE': context.keyfile})
ctl.update({'cacert': context.certfile, 'certfile': context.certfile, 'keyfile': context.keyfile}) ctl.update({'cacert': context.certfile, 'certfile': context.certfile, 'keyfile': context.keyfile})
context.request_executor = PatroniRequest({'ctl': ctl}, True) context.request_executor = PatroniRequest({'ctl': ctl}, True)
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context) context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
@@ -1135,3 +1146,5 @@ def before_scenario(context, scenario):
break break
if 'dcs-failsafe' in scenario.effective_tags and not context.dcs_ctl._handle: if 'dcs-failsafe' in scenario.effective_tags and not context.dcs_ctl._handle:
scenario.skip('it is not possible to control state of {0} from tests'.format(context.dcs_ctl.name())) scenario.skip('it is not possible to control state of {0} from tests'.format(context.dcs_ctl.name()))
if 'reject-duplicate-name' in scenario.effective_tags and context.dcs_ctl.name() == 'raft':
scenario.skip('Flaky test with Raft')
+5 -5
View File
@@ -10,7 +10,7 @@ Feature: ignored slots
When I shut down postgres1 When I shut down postgres1
And I start postgres1 And I start postgres1
Then postgres1 is a leader after 10 seconds Then postgres1 is a leader after 10 seconds
And "members/postgres1" key in DCS has role=master after 3 seconds And "members/postgres1" key in DCS has role=master after 10 seconds
# Make sure Patroni has finished telling Postgres it should be accepting writes. # Make sure Patroni has finished telling Postgres it should be accepting writes.
And postgres1 role is the primary after 20 seconds And postgres1 role is the primary after 20 seconds
# 1. Create our test logical replication slot. # 1. Create our test logical replication slot.
@@ -31,19 +31,19 @@ Feature: ignored slots
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
When I start postgres0 When I start postgres0
Then "members/postgres0" key in DCS has role=replica after 3 seconds Then "members/postgres0" key in DCS has role=replica after 10 seconds
And postgres0 role is the secondary after 20 seconds And postgres0 role is the secondary after 20 seconds
# Verify that the replica has advanced beyond the point in the WAL # Verify that the replica has advanced beyond the point in the WAL
# where we created the replication slot so that on the next failover # where we created the replication slot so that on the next failover
# cycle we don't accidentally rewind to before the slot creation. # cycle we don't accidentally rewind to before the slot creation.
And replication works from postgres1 to postgres0 after 20 seconds And replication works from postgres1 to postgres0 after 20 seconds
When I shut down postgres1 When I shut down postgres1
Then "members/postgres0" key in DCS has role=master after 3 seconds Then "members/postgres0" key in DCS has role=master after 10 seconds
# 2. After a failover the server (now a replica) still has the slot. # 2. After a failover the server (now a replica) still has the slot.
When I start postgres1 When I start postgres1
Then postgres1 role is the secondary after 20 seconds Then postgres1 role is the secondary after 20 seconds
And "members/postgres1" key in DCS has role=replica after 3 seconds And "members/postgres1" key in DCS has role=replica after 10 seconds
# give Patroni time to sync replication slots # give Patroni time to sync replication slots
And I sleep for 2 seconds And I sleep for 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
@@ -54,7 +54,7 @@ Feature: ignored slots
# 3. After a failover the server (now a primary) still has the slot. # 3. After a failover the server (now a primary) still has the slot.
When I shut down postgres0 When I shut down postgres0
Then "members/postgres1" key in DCS has role=master after 3 seconds Then "members/postgres1" key in DCS has role=master after 10 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
+13 -13
View File
@@ -35,30 +35,30 @@ Scenario: check local configuration reload
Then I receive a response code 202 Then I receive a response code 202
Scenario: check dynamic configuration change via DCS Scenario: check dynamic configuration change via DCS
Given I run patronictl.py edit-config -s 'ttl=10' -p 'max_connections=101' --force batman Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "postgresql": {"parameters": {"max_connections": "101"}}}
Then I receive a response returncode 0 Then I receive a response code 200
And I receive a response output "+ttl: 10"
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds
When I issue a GET request to http://127.0.0.1:8008/config When I issue a GET request to http://127.0.0.1:8008/config
Then I receive a response code 200 Then I receive a response code 200
And I receive a response ttl 10 And I receive a response ttl 20
When I issue a GET request to http://127.0.0.1:8008/patroni When I issue a GET request to http://127.0.0.1:8008/patroni
Then I receive a response code 200 Then I receive a response code 200
And I receive a response tags {'new_tag': 'new_value'} And I receive a response tags {'new_tag': 'new_value'}
And I sleep for 4 seconds And I sleep for 4 seconds
Scenario: check the scheduled restart Scenario: check the scheduled restart
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}} Given I run patronictl.py edit-config -p 'superuser_reserved_connections=6' --force batman
Then I receive a response code 200 Then I receive a response returncode 0
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds And I receive a response output "+ superuser_reserved_connections: 6"
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"} Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"}
Then I receive a response code 202 Then I receive a response code 202
And I sleep for 8 seconds And I sleep for 8 seconds
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"} Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"}
Then I receive a response code 202 Then I receive a response code 202
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
And postgres0 role is the primary after 10 seconds And postgres0 role is the primary after 10 seconds
Scenario: check API requests for the primary-replica pair in the pause mode Scenario: check API requests for the primary-replica pair in the pause mode
Given I start postgres1 Given I start postgres1
@@ -79,12 +79,12 @@ Scenario: check API requests for the primary-replica pair in the pause mode
When I run patronictl.py reinit batman postgres1 --force When I run patronictl.py reinit batman postgres1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And I receive a response output "Success: reinitialize for member postgres1" And I receive a response output "Success: reinitialize for member postgres1"
And postgres1 role is the secondary after 30 seconds
And replication works from postgres0 to postgres1 after 20 seconds
When I run patronictl.py restart batman postgres0 --force When I run patronictl.py restart batman postgres0 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And I receive a response output "Success: restart on member postgres0" And I receive a response output "Success: restart on member postgres0"
And postgres0 role is the primary after 5 seconds And postgres0 role is the primary after 5 seconds
When I sleep for 10 seconds
Then postgres1 role is the secondary after 15 seconds
Scenario: check the switchover via the API in the pause mode Scenario: check the switchover via the API in the pause mode
Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0", "candidate": "postgres1"} Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0", "candidate": "postgres1"}
+24
View File
@@ -0,0 +1,24 @@
Feature: recovery
We want to check that crashed postgres is started back
Scenario: check that timeline is not incremented when primary is started after crash
Given I start postgres0
Then postgres0 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I start postgres1
And I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
When I kill postmaster on postgres0
Then postgres0 role is the primary after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
And I receive a response role master
And I receive a response timeline 1
Scenario: check immediate failover when master_start_timeout=0
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"master_start_timeout": 0}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/config contains master_start_timeout after 10 seconds
When I kill postmaster on postgres0
Then postgres1 is a leader after 10 seconds
And postgres1 role is the primary after 10 seconds
+10
View File
@@ -13,6 +13,10 @@ Feature: standby cluster
When I start postgres0 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
When I issue a GET request to http://127.0.0.1:8008/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And "members/postgres0" key in DCS has replication_state=streaming after 10 seconds
@slot-advance @slot-advance
Scenario: check permanent logical slots are synced to the replica Scenario: check permanent logical slots are synced to the replica
@@ -34,6 +38,9 @@ Feature: standby cluster
Then postgres1 is a leader of batman1 after 10 seconds Then postgres1 is a leader of batman1 after 10 seconds
When I add the table foo to postgres0 When I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds Then table foo is present on postgres1 after 20 seconds
When I issue a GET request to http://127.0.0.1:8009/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And I sleep for 3 seconds And I sleep for 3 seconds
When I issue a GET request to http://127.0.0.1:8009/primary When I issue a GET request to http://127.0.0.1:8009/primary
Then I receive a response code 503 Then I receive a response code 503
@@ -44,6 +51,9 @@ 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
When I issue a GET request to http://127.0.0.1:8010/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And postgres1 does not have a logical replication slot named test_logical And postgres1 does not have a logical replication slot named test_logical
Scenario: check failover Scenario: check failover
+26 -3
View File
@@ -9,6 +9,22 @@ def start_patroni(context, name):
return context.pctl.start(name) return context.pctl.start(name)
@step('I start duplicate {name:w} on port {port:d}')
def start_duplicate_patroni(context, name, port):
config = {
"name": name,
"restapi": {
"listen": "127.0.0.1:{0}".format(port)
}
}
try:
context.pctl.start('dup-' + name, custom_config=config)
assert False, "Process was expected to fail"
except AssertionError as e:
assert 'is not running after being started' in str(e), \
"No error was raised by duplicate start of {0} ".format(name)
@step('I shut down {name:w}') @step('I shut down {name:w}')
def stop_patroni(context, name): def stop_patroni(context, name):
return context.pctl.stop(name, timeout=60) return context.pctl.stop(name, timeout=60)
@@ -38,7 +54,7 @@ def toggle_wal_replay(context, action, pg_name):
# pause or resume the wal replay process # pause or resume the wal replay process
try: try:
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0] version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
wal_name = 'xlog' if int(version)/10000 < 10 else 'wal' wal_name = 'xlog' if int(version) / 10000 < 10 else 'wal'
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action)) context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
except pg.Error as e: except pg.Error as e:
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e) assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
@@ -72,14 +88,14 @@ def table_is_present_on(context, table_name, pg_name, max_replication_delay):
break break
sleep(1) sleep(1)
else: else:
assert False,\ assert False, \
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay) "Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds') @then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
def check_role(context, pg_name, pg_role, max_promotion_timeout): def check_role(context, pg_name, pg_role, max_promotion_timeout):
max_promotion_timeout *= context.timeout_multiplier max_promotion_timeout *= context.timeout_multiplier
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)),\ assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)), \
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout) "{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
@@ -90,3 +106,10 @@ def replication_works(context, primary, replica, time_limit):
When I add the table test_{0} to {1} When I add the table test_{0} to {1}
Then table test_{0} is present on {2} after {3} seconds Then table test_{0} is present on {2} after {3} seconds
""".format(int(time()), primary, replica, time_limit)) """.format(int(time()), primary, replica, time_limit))
@then('there is a "{message}" {level:w} in the {node} patroni log')
def check_patroni_log(context, message, level, node):
messsages_of_level = context.pctl.read_patroni_log(node, level)
assert any(message in line for line in messsages_of_level), \
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
+21 -9
View File
@@ -35,7 +35,7 @@ def check_group_member(context, name, group, key, value, time_limit):
except Exception: except Exception:
pass pass
time.sleep(1) time.sleep(1)
assert False, ("{0} in a group {1} does not have {2}={3} (found {4}) in dcs" + assert False, ("{0} in a group {1} does not have {2}={3} (found {4}) in dcs"
" after {5} seconds").format(name, group, key, value, response, time_limit) " after {5} seconds").format(name, group, key, value, response, time_limit)
@@ -44,12 +44,24 @@ def start_citus(context, name, group):
return context.pctl.start(name, custom_config={"citus": {"database": "postgres", "group": int(group)}}) return context.pctl.start(name, custom_config={"citus": {"database": "postgres", "group": int(group)}})
@step('{name1:w} is registered in the {name2:w} as the worker in group {group:d}') @step('{name1:w} is registered in the {name2:w} as the {role:w} in group {group:d} after {time_limit:d} seconds')
def check_registration(context, name1, name2, group): def check_registration(context, name1, name2, role, group, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
worker_port = int(context.pctl.query(name1, "SHOW port").fetchone()[0]) worker_port = int(context.pctl.query(name1, "SHOW port").fetchone()[0])
r = context.pctl.query(name2, "SELECT nodeport FROM pg_catalog.pg_dist_node WHERE groupid = {0}".format(group))
assert worker_port == r.fetchone()[0],\ while time.time() < max_time:
"Worker {0} is not registered in pg_dist_node on the coordinator {1}".format(name1, name2) try:
cur = context.pctl.query(name2, "SELECT nodeport, noderole"
" FROM pg_catalog.pg_dist_node WHERE groupid = {0}".format(group))
mapping = {r[0]: r[1] for r in cur}
if mapping.get(worker_port) == role:
return
except Exception:
pass
time.sleep(1)
assert False, "Node {0} is not registered in pg_dist_node on the node {1}".format(name1, name2)
@step('I create a distributed table on {name:w}') @step('I create a distributed table on {name:w}')
@@ -93,7 +105,7 @@ def thread_is_alive(context):
@step("I stop a thread") @step("I stop a thread")
def stop_insert_thread(context): def stop_insert_thread(context):
context.thread_stop_event.set() context.thread_stop_event.set()
context.thread.join(1*context.timeout_multiplier) context.thread.join(1 * context.timeout_multiplier)
assert not context.thread.is_alive(), "Thread is still alive" assert not context.thread.is_alive(), "Thread is still alive"
@@ -113,5 +125,5 @@ def check_transaction(context, name):
@step("a transaction finishes in {timeout:d} seconds") @step("a transaction finishes in {timeout:d} seconds")
def check_transaction_timeout(context, timeout): def check_transaction_timeout(context, timeout):
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout,\ assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \
"a transaction finished earlier than in {0} seconds".format(timeout) "a transaction finished earlier than in {0} seconds".format(timeout)
+2 -2
View File
@@ -98,7 +98,7 @@ def do_run(context, cmd):
@then('I receive a response {component:w} {data}') @then('I receive a response {component:w} {data}')
def check_response(context, component, data): def check_response(context, component, data):
if component == 'code': if component == 'code':
assert context.status_code == int(data),\ assert context.status_code == int(data), \
"status code {0} != {1}, response: {2}".format(context.status_code, data, context.response) "status code {0} != {1}, response: {2}".format(context.status_code, data, context.response)
elif component == 'returncode': elif component == 'returncode':
assert context.status_code == int(data), "return code {0} != {1}, {2}".format(context.status_code, assert context.status_code == int(data), "return code {0} != {1}, {2}".format(context.status_code,
@@ -158,7 +158,7 @@ def check_http_response(context, url, value, timeout, negate=False):
break break
time.sleep(1) time.sleep(1)
else: else:
assert False,\ assert False, \
"Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout) "Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout)
+2 -2
View File
@@ -16,8 +16,8 @@ def start_patroni(context, name, cluster_name):
"postgresql": { "postgresql": {
"callbacks": callbacks(context, name), "callbacks": callbacks(context, name),
"backup_restore": { "backup_restore": {
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir=" + "command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir="
os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))} + os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
} }
}) })
+8 -4
View File
@@ -7,11 +7,13 @@ RUN export DEBIAN_FRONTEND=noninteractive \
&& apt-get upgrade -y \ && apt-get upgrade -y \
&& apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \ && apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \ | grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel \ | xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \
## Make sure we have a en_US.UTF-8 locale available ## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \ && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& curl https://install.citusdata.com/community/deb.sh | bash \ && echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& apt-get -y install postgresql-15-citus-11.2 \ && curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3 \
&& pip3 install setuptools \ && pip3 install setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ && pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \ && PGHOME=/home/postgres \
@@ -32,7 +34,9 @@ ADD entrypoint.sh /
ENV PGSSLMODE=verify-ca PGSSLKEY=/etc/ssl/private/ssl-cert-snakeoil.key PGSSLCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem ENV PGSSLMODE=verify-ca PGSSLKEY=/etc/ssl/private/ssl-cert-snakeoil.key PGSSLCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
RUN sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' /entrypoint.sh \ RUN sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' /entrypoint.sh \
&& sed -i "s|^ postgresql:|&\n pg_hba:\n - local all all trust\n - hostssl replication all all md5 clientcert=$PGSSLMODE\n - hostssl all all all md5 clientcert=$PGSSLMODE\n parameters:\n max_connections: 100\n shared_buffers: 16MB\n ssl: 'on'\n ssl_ca_file: $PGSSLROOTCERT\n ssl_cert_file: $PGSSLCERT\n ssl_key_file: $PGSSLKEY\n citus.node_conninfo: 'sslrootcert=$PGSSLROOTCERT sslkey=$PGSSLKEY sslcert=$PGSSLCERT sslmode=$PGSSLMODE'|" /entrypoint.sh \ && sed -i "s|^ postgresql:|&\n parameters:\n max_connections: 100\n shared_buffers: 16MB\n ssl: 'on'\n ssl_ca_file: $PGSSLROOTCERT\n ssl_cert_file: $PGSSLCERT\n ssl_key_file: $PGSSLKEY\n citus.node_conninfo: 'sslrootcert=$PGSSLROOTCERT sslkey=$PGSSLKEY sslcert=$PGSSLCERT sslmode=$PGSSLMODE'|" /entrypoint.sh \
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' /entrypoint.sh \
&& sed -i "s/^\(.*\) \(.*\) \(.*\) \(.*\) \(.*\) md5.*$/\1 hostssl \3 \4 all md5 clientcert=$PGSSLMODE/" /entrypoint.sh \
&& sed -i "s#^ \(superuser\|replication\):#&\n sslmode: $PGSSLMODE\n sslkey: $PGSSLKEY\n sslcert: $PGSSLCERT\n sslrootcert: $PGSSLROOTCERT#" /entrypoint.sh && sed -i "s#^ \(superuser\|replication\):#&\n sslmode: $PGSSLMODE\n sslkey: $PGSSLKEY\n sslcert: $PGSSLCERT\n sslrootcert: $PGSSLROOTCERT#" /entrypoint.sh
EXPOSE 5432 8008 EXPOSE 5432 8008
+3 -3
View File
@@ -12,15 +12,15 @@ bootstrap:
dcs: dcs:
postgresql: postgresql:
use_pg_rewind: true use_pg_rewind: true
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
initdb: initdb:
- auth-host: md5 - auth-host: md5
- auth-local: trust - auth-local: trust
- encoding: UTF8 - encoding: UTF8
- locale: en_US.UTF-8 - locale: en_US.UTF-8
- data-checksums - data-checksums
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
restapi: restapi:
connect_address: '${PATRONI_KUBERNETES_POD_IP}:8008' connect_address: '${PATRONI_KUBERNETES_POD_IP}:8008'
postgresql: postgresql:
+4 -1
View File
@@ -16,7 +16,10 @@ def hiddenimports():
a = Analysis(['patroni/__main__.py'], a = Analysis(['patroni/__main__.py'],
pathex=[], pathex=[],
binaries=None, binaries=None,
datas=None, datas=[
('patroni/postgresql/available_parameters/*.yml', 'patroni/postgresql/available_parameters'),
('patroni/postgresql/available_parameters/*.yaml', 'patroni/postgresql/available_parameters'),
],
hiddenimports=hiddenimports(), hiddenimports=hiddenimports(),
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
+61 -8
View File
@@ -1,17 +1,54 @@
"""Define general variables and functions for :mod:`patroni`.
:var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables.
:var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables.
:var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work.
"""
import sys import sys
from typing import Any, Callable, Iterator, Tuple
PATRONI_ENV_PREFIX = 'PATRONI_' PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_' KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
MIN_PSYCOPG2 = (2, 5, 4) MIN_PSYCOPG2 = (2, 5, 4)
def fatal(string, *args): def fatal(string: str, *args: Any) -> None:
sys.stderr.write('FATAL: ' + string.format(*args) + '\n') """Write a fatal message to stderr and exit with code ``1``.
sys.exit(1)
:param string: message to be written before exiting.
"""
sys.exit('FATAL: ' + string.format(*args))
def parse_version(version): def parse_version(version: str) -> Tuple[int, ...]:
def _parse_version(version): """Convert *version* from human-readable format to tuple of integers.
.. note::
Designed for easy comparison of software versions in Python.
:param version: human-readable software version, e.g. ``2.5.4``.
:returns: tuple of *version* parts, each part as an integer.
:Example:
>>> parse_version('2.5.4')
(2, 5, 4)
"""
def _parse_version(version: str) -> Iterator[int]:
"""Yield each part of a human-readable version string as an integer.
:param version: human-readable software version, e.g. ``2.5.4``.
:yields: each part of *version* as an integer.
:Example:
>>> tuple(_parse_version('2.5.4'))
(2, 5, 4)
"""
for e in version.split('.'): for e in version.split('.'):
try: try:
yield int(e) yield int(e)
@@ -20,10 +57,25 @@ def parse_version(version):
return tuple(_parse_version(version.split(' ')[0])) return tuple(_parse_version(version.split(' ')[0]))
# We pass MIN_PSYCOPG2 and parse_version as arguments to simplify usage of check_psycopg from the setup.py def check_psycopg(_min_psycopg2: Tuple[int, ...] = MIN_PSYCOPG2,
def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version): _parse_version: Callable[[str], Tuple[int, ...]] = parse_version) -> None:
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
.. note::
We pass ``MIN_PSYCOPG2`` and :func:`parse_version` as arguments to simplify usage of :func:`check_psycopg` from
the ``setup.py``.
.. note::
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
If nothing meeting the requirements is found, then exit with a fatal message.
:param _min_psycopg2: minimum required version in case :mod:`psycopg2` is chosen.
:param _parse_version: function used to parse :mod:`psycopg2`/:mod:`psycopg` version into a comparable object.
"""
min_psycopg2_str = '.'.join(map(str, _min_psycopg2)) min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
# try psycopg2
try: try:
from psycopg2 import __version__ from psycopg2 import __version__
if _parse_version(__version__) >= _min_psycopg2: if _parse_version(__version__) >= _min_psycopg2:
@@ -32,10 +84,11 @@ def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version):
except ImportError: except ImportError:
version_str = None version_str = None
# try psycopg3
try: try:
from psycopg import __version__ from psycopg import __version__
except ImportError: except ImportError:
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str) error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
if version_str: if version_str is not None:
error += ', but only psycopg2=={0} is available'.format(version_str) error += ', but only psycopg2=={0} is available'.format(version_str)
fatal(error) fatal(error)
+72 -24
View File
@@ -1,16 +1,23 @@
import logging import logging
import os import os
import signal import signal
import sys
import time import time
from patroni.daemon import AbstractPatroniDaemon, abstract_main from argparse import Namespace
from typing import Any, Dict, Optional, TYPE_CHECKING
from patroni.daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser
if TYPE_CHECKING: # pragma: no cover
from .config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Patroni(AbstractPatroniDaemon): class Patroni(AbstractPatroniDaemon):
def __init__(self, config): def __init__(self, config: 'Config') -> None:
from patroni.api import RestApiServer from patroni.api import RestApiServer
from patroni.dcs import get_dcs from patroni.dcs import get_dcs
from patroni.ha import Ha from patroni.ha import Ha
@@ -23,19 +30,22 @@ class Patroni(AbstractPatroniDaemon):
self.version = __version__ self.version = __version__
self.dcs = get_dcs(self.config) self.dcs = get_dcs(self.config)
self.request = PatroniRequest(self.config, True)
self.ensure_unique_name()
self.watchdog = Watchdog(self.config) self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration() self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql']) self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi']) self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self) self.ha = Ha(self)
self.tags = self.get_tags() self.tags = self.get_tags()
self.next_run = time.time() self.next_run = time.time()
self.scheduled_restart = {} self.scheduled_restart: Dict[str, Any] = {}
def load_dynamic_configuration(self): def load_dynamic_configuration(self) -> None:
from patroni.exceptions import DCSError from patroni.exceptions import DCSError
while True: while True:
try: try:
@@ -53,19 +63,36 @@ class Patroni(AbstractPatroniDaemon):
logger.warning('Can not get cluster from dcs') logger.warning('Can not get cluster from dcs')
time.sleep(5) time.sleep(5)
def get_tags(self): def ensure_unique_name(self) -> None:
"""A helper method to prevent splitbrain from operator naming error."""
from patroni.dcs import Member
cluster = self.dcs.get_cluster()
if not cluster:
return
member = cluster.get_member(self.config['name'], False)
if not isinstance(member, Member):
return
try:
_ = self.request(member, endpoint="/liveness")
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
sys.exit(1)
except Exception:
return
def get_tags(self) -> Dict[str, Any]:
return {tag: value for tag, value in self.config.get('tags', {}).items() return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value} if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property @property
def nofailover(self): def nofailover(self) -> bool:
return bool(self.tags.get('nofailover', False)) return bool(self.tags.get('nofailover', False))
@property @property
def nosync(self): def nosync(self) -> bool:
return bool(self.tags.get('nosync', False)) return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=False): def reload_config(self, sighup: bool = False, local: Optional[bool] = False) -> None:
try: try:
super(Patroni, self).reload_config(sighup, local) super(Patroni, self).reload_config(sighup, local)
if local: if local:
@@ -87,7 +114,7 @@ class Patroni(AbstractPatroniDaemon):
def noloadbalance(self): def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False)) return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self): def schedule_next_run(self) -> None:
self.next_run += self.dcs.loop_wait self.next_run += self.dcs.loop_wait
current_time = time.time() current_time = time.time()
nap_time = self.next_run - current_time nap_time = self.next_run - current_time
@@ -100,12 +127,12 @@ class Patroni(AbstractPatroniDaemon):
elif self.ha.watch(nap_time): elif self.ha.watch(nap_time):
self.next_run = time.time() self.next_run = time.time()
def run(self): def run(self) -> None:
self.api.start() self.api.start()
self.next_run = time.time() self.next_run = time.time()
super(Patroni, self).run() super(Patroni, self).run()
def _run_cycle(self): def _run_cycle(self) -> None:
logger.info(self.ha.run_cycle()) logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \ if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
@@ -117,7 +144,7 @@ class Patroni(AbstractPatroniDaemon):
self.schedule_next_run() self.schedule_next_run()
def _shutdown(self): def _shutdown(self) -> None:
try: try:
self.api.shutdown() self.api.shutdown()
except Exception: except Exception:
@@ -128,26 +155,47 @@ class Patroni(AbstractPatroniDaemon):
logger.exception('Exception during Ha.shutdown') logger.exception('Exception during Ha.shutdown')
def patroni_main(): def patroni_main(configfile: str) -> None:
from multiprocessing import freeze_support from multiprocessing import freeze_support
from patroni.validator import schema
freeze_support() freeze_support()
abstract_main(Patroni, schema) abstract_main(Patroni, configfile)
def main(): def process_arguments() -> Namespace:
parser = get_base_arg_parser()
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
args = parser.parse_args()
if args.validate_config:
from patroni.validator import schema
from patroni.config import Config, ConfigParseError
try:
Config(args.configfile, validator=schema)
sys.exit()
except ConfigParseError as e:
sys.exit(e.value)
return args
def main() -> None:
from patroni import check_psycopg
args = process_arguments()
check_psycopg()
if os.getpid() != 1: if os.getpid() != 1:
from patroni import check_psycopg return patroni_main(args.configfile)
check_psycopg()
return patroni_main()
# Patroni started with PID=1, it looks like we are in the container # Patroni started with PID=1, it looks like we are in the container
from types import FrameType
pid = 0 pid = 0
# Looks like we are in a docker, so we will act like init # Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame): def sigchld_handler(signo: int, stack_frame: Optional[FrameType]) -> None:
try: try:
while True: while True:
ret = os.waitpid(-1, os.WNOHANG) ret = os.waitpid(-1, os.WNOHANG)
@@ -158,7 +206,7 @@ def main():
except OSError: except OSError:
pass pass
def passtochild(signo, stack_frame): def passtochild(signo: int, stack_frame: Optional[FrameType]):
if pid: if pid:
os.kill(pid, signo) os.kill(pid, signo)
@@ -173,7 +221,7 @@ def main():
signal.signal(signal.SIGTERM, passtochild) signal.signal(signal.SIGTERM, passtochild)
import multiprocessing import multiprocessing
patroni = multiprocessing.Process(target=patroni_main) patroni = multiprocessing.Process(target=patroni_main, args=(args.configfile,))
patroni.start() patroni.start()
pid = patroni.pid pid = patroni.pid
patroni.join() patroni.join()
+843 -145
View File
File diff suppressed because it is too large Load Diff
+125 -27
View File
@@ -1,5 +1,11 @@
"""Implement facilities for executing asynchronous tasks."""
import logging import logging
from threading import Event, Lock, RLock, Thread from threading import Event, Lock, RLock, Thread
from types import TracebackType
from typing import Any, Callable, Optional, Tuple, Type
from .postgresql.cancellable import CancellableSubprocess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -8,65 +14,110 @@ class CriticalTask(object):
"""Represents a critical task in a background process that we either need to cancel or get the result of. """Represents a critical task in a background process that we either need to cancel or get the result of.
Fields of this object may be accessed only when holding a lock on it. To perform the critical task the background Fields of this object may be accessed only when holding a lock on it. To perform the critical task the background
thread must, while holding lock on this object, check `is_cancelled` flag, run the task and mark the task as thread must, while holding lock on this object, check ``is_cancelled`` flag, run the task and mark the task as
complete using `complete()`. complete using :func:`complete`.
The main thread must hold async lock to prevent the task from completing, hold lock on critical task object, The main thread must hold async lock to prevent the task from completing, hold lock on critical task object,
call cancel. If the task has completed `cancel()` will return False and `result` field will contain the result of call :func:`cancel`. If the task has completed :func:`cancel` will return ``False`` and ``result`` field will
the task. When cancel returns True it is guaranteed that the background task will notice the `is_cancelled` flag. contain the result of the task. When :func:`cancel` returns ``True`` it is guaranteed that the background task will
notice the ``is_cancelled`` flag.
:ivar is_cancelled: if the critical task has been cancelled.
:ivar result: contains the result of the task, if it has already been completed.
""" """
def __init__(self):
def __init__(self) -> None:
"""Create a new instance of :class:`CriticalTask`.
Instantiate the lock and the task control attributes.
"""
self._lock = Lock() self._lock = Lock()
self.is_cancelled = False self.is_cancelled = False
self.result = None self.result = None
def reset(self): def reset(self) -> None:
"""Must be called every time the background task is finished. """Must be called every time the background task is finished.
Must be called from async thread. Caller must hold lock on async executor when calling.""" .. note::
Must be called from async thread. Caller must hold lock on async executor when calling.
"""
self.is_cancelled = False self.is_cancelled = False
self.result = None self.result = None
def cancel(self): def cancel(self) -> bool:
"""Tries to cancel the task, returns True if the task has already run. """Tries to cancel the task.
Caller must hold lock on async executor and the task when calling.""" .. note::
Caller must hold lock on async executor and the task when calling.
:returns: ``False`` if the task has already run, or ``True`` it has been cancelled.
"""
if self.result is not None: if self.result is not None:
return False return False
self.is_cancelled = True self.is_cancelled = True
return True return True
def complete(self, result): def complete(self, result: Any) -> None:
"""Mark task as completed along with a result. """Mark task as completed along with a *result*.
Must be called from async thread. Caller must hold lock on task when calling.""" .. note::
Must be called from async thread. Caller must hold lock on task when calling.
"""
self.result = result self.result = result
def __enter__(self): def __enter__(self) -> 'CriticalTask':
"""Acquire the object lock when entering the context manager."""
self._lock.acquire() self._lock.acquire()
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
"""Release the object lock when exiting the context manager."""
self._lock.release() self._lock.release()
class AsyncExecutor(object): class AsyncExecutor(object):
"""Asynchronous executor of (long) tasks.
def __init__(self, cancellable, ha_wakeup): :ivar critical_task: a :class:`CriticalTask` instance to handle execution of critical background tasks.
"""
def __init__(self, cancellable: CancellableSubprocess, ha_wakeup: Callable[..., None]) -> None:
"""Create a new instance of :class:`AsyncExecutor`.
Configure the given *cancellable* and *ha_wakeup*, initializes the control attributes, and instantiate the lock
and event objects that are used to access attributes and manage communication between threads.
:param cancellable: a subprocess that supports being cancelled.
:param ha_wakeup: function to wake up the HA loop.
"""
self._cancellable = cancellable self._cancellable = cancellable
self._ha_wakeup = ha_wakeup self._ha_wakeup = ha_wakeup
self._thread_lock = RLock() self._thread_lock = RLock()
self._scheduled_action = None self._scheduled_action: Optional[str] = None
self._scheduled_action_lock = RLock() self._scheduled_action_lock = RLock()
self._is_cancelled = False self._is_cancelled = False
self._finish_event = Event() self._finish_event = Event()
self.critical_task = CriticalTask() self.critical_task = CriticalTask()
@property @property
def busy(self): def busy(self) -> bool:
"""``True`` if there is an action scheduled to occur, else ``False``."""
return self.scheduled_action is not None return self.scheduled_action is not None
def schedule(self, action): def schedule(self, action: str) -> Optional[str]:
"""Schedule *action* to be executed.
.. note::
Must be called before executing a task.
.. note::
*action* can only be scheduled if there is no other action currently scheduled.
:param action: action to be executed.
:returns: ``None`` if *action* has been successfully scheduled, or the previously scheduled action, if any.
"""
with self._scheduled_action_lock: with self._scheduled_action_lock:
if self._scheduled_action is not None: if self._scheduled_action is not None:
return self._scheduled_action return self._scheduled_action
@@ -76,15 +127,33 @@ class AsyncExecutor(object):
return None return None
@property @property
def scheduled_action(self): def scheduled_action(self) -> Optional[str]:
"""The currently scheduled action, if any, else ``None``."""
with self._scheduled_action_lock: with self._scheduled_action_lock:
return self._scheduled_action return self._scheduled_action
def reset_scheduled_action(self): def reset_scheduled_action(self) -> None:
"""Unschedule a previously scheduled action, if any.
.. note::
Must be called once the scheduled task finishes or is cancelled.
"""
with self._scheduled_action_lock: with self._scheduled_action_lock:
self._scheduled_action = None self._scheduled_action = None
def run(self, func, args=()): def run(self, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> Optional[Any]:
"""Run *func* with *args*.
.. note::
Expected to be executed through a thread.
:param func: function to be run. If it returns anything other than ``None``, HA loop will be woken up at the end
of :func:`run` execution.
:param args: arguments to be passed to *func*.
:returns: ``None`` if *func* execution has been cancelled or faced any exception, otherwise the result of
*func*.
"""
wakeup = False wakeup = False
try: try:
with self: with self:
@@ -107,16 +176,37 @@ class AsyncExecutor(object):
if wakeup is not None: if wakeup is not None:
self._ha_wakeup() self._ha_wakeup()
def run_async(self, func, args=()): def run_async(self, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> None:
"""Start an async thread that runs *func* with *args*.
:param func: function to be run. Will be passed through args to :class:`~threading.Thread` with a target of
:func:`run`.
:param args: arguments to be passed along to :class:`~threading.Thread` with *func*.
"""
Thread(target=self.run, args=(func, args)).start() Thread(target=self.run, args=(func, args)).start()
def try_run_async(self, action, func, args=()): def try_run_async(self, action: str, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> Optional[str]:
"""Try to run an async task, if none is currently being executed.
:param action: name of the task to be executed.
:param func: actual function that performs the task *action*.
:param args: arguments to be passed to *func*.
:returns: ``None`` if *func* was scheduled successfully, otherwise an error message informing of an already
ongoing task.
"""
prev = self.schedule(action) prev = self.schedule(action)
if prev is None: if prev is None:
return self.run_async(func, args) return self.run_async(func, args)
return 'Failed to run {0}, {1} is already in progress'.format(action, prev) return 'Failed to run {0}, {1} is already in progress'.format(action, prev)
def cancel(self): def cancel(self) -> None:
"""Request cancellation of a scheduled async task, if any.
.. note::
Wait until task is cancelled before returning control to caller.
"""
with self: with self:
with self._scheduled_action_lock: with self._scheduled_action_lock:
if self._scheduled_action is None: if self._scheduled_action is None:
@@ -130,8 +220,16 @@ class AsyncExecutor(object):
with self: with self:
self.reset_scheduled_action() self.reset_scheduled_action()
def __enter__(self): def __enter__(self) -> 'AsyncExecutor':
"""Acquire the thread lock when entering the context manager."""
self._thread_lock.acquire() self._thread_lock.acquire()
return self
def __exit__(self, *args): def __exit__(self, exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
"""Release the thread lock when exiting the context manager.
.. note::
The arguments are not used, but we need them to match the expected method signature.
"""
self._thread_lock.release() self._thread_lock.release()
+202
View File
@@ -0,0 +1,202 @@
"""Patroni custom object types somewhat like :mod:`collections` module.
Provides a case insensitive :class:`dict` and :class:`set` object types.
"""
from collections import OrderedDict
from typing import Any, Collection, Dict, Iterator, MutableMapping, MutableSet, Optional
class CaseInsensitiveSet(MutableSet[str]):
"""A case-insensitive :class:`set`-like object.
Implements all methods and operations of :class:`~typing.MutableSet`. All values are expected to be strings.
The structure remembers the case of the last value set, however, contains testing is case insensitive.
"""
def __init__(self, values: Optional[Collection[str]] = None) -> None:
"""Create a new instance of :class:`CaseInsensitiveSet` with the given *values*.
:param values: values to be added to the set.
"""
self._values: Dict[str, str] = {}
for v in values or ():
self.add(v)
def __repr__(self) -> str:
"""Get a string representation of the set.
Provide a helpful way of recreating the set.
:returns: representation of the set, showing its values.
:Example:
>>> repr(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2'))) # doctest: +ELLIPSIS
"<CaseInsensitiveSet('1', 'TESt', 'test2') at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, tuple(self._values.values()), id(self))
def __str__(self) -> str:
"""Get set values for printing.
:returns: set of values in string format.
:Example:
>>> str(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2'))) # doctest: +SKIP
"{'TESt', 'test2', '1'}"
"""
return str(set(self._values.values()))
def __contains__(self, value: str) -> bool:
"""Check if set contains *value*.
The check is performed case-insensitively.
:param value: value to be checked.
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
"""
return value.lower() in self._values
def __iter__(self) -> Iterator[str]:
"""Iterate over the values in this set.
:yields: values from set.
"""
return iter(self._values.values())
def __len__(self) -> int:
"""Get the length of this set.
:returns: number of values in the set.
:Example:
>>> len(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2')))
3
"""
return len(self._values)
def add(self, value: str) -> None:
"""Add *value* to this set.
Search is performed case-insensitively. If *value* is already in the set, overwrite it with *value*, so we
"remember" the last case of *value*.
:param value: value to be added to the set.
"""
self._values[value.lower()] = value
def discard(self, value: str) -> None:
"""Remove *value* from this set.
Search is performed case-insensitively. If *value* is not present in the set, no exception is raised.
:param value: value to be removed from the set.
"""
self._values.pop(value.lower(), None)
def issubset(self, other: 'CaseInsensitiveSet') -> bool:
"""Check if this set is a subset of *other*.
:param other: another set to be compared with this set.
:returns: ``True`` if this set is a subset of *other*, else ``False``.
"""
return self <= other
class CaseInsensitiveDict(MutableMapping[str, Any]):
"""A case-insensitive :class:`dict`-like object.
Implements all methods and operations of :class:`~typing.MutableMapping` as well as :class:`dict`'s
:func:`~dict.copy`. All keys are expected to be strings. The structure remembers the case of the last key to be set,
and :func:`iter`, :func:`dict.keys`, :func:`dict.items`, :func:`dict.iterkeys`, and :func:`dict.iteritems` will
contain case-sensitive keys. However, querying and contains testing is case insensitive.
"""
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
"""Create a new instance of :class:`CaseInsensitiveDict` with the given *data*.
:param data: initial dictionary to create a :class:`CaseInsensitiveDict` from.
"""
self._values: OrderedDict[str, Any] = OrderedDict()
self.update(data or {})
def __setitem__(self, key: str, value: Any) -> None:
"""Assign *value* to *key* in this dict.
*key* is searched/stored case-insensitively in the dict. The corresponding value in the dict is a tuple of:
* original *key*;
* *value*.
:param key: key to be created or updated in the dict.
:param value: value for *key*.
"""
self._values[key.lower()] = (key, value)
def __getitem__(self, key: str) -> Any:
"""Get the value corresponding to *key*.
*key* is searched case-insensitively in the dict.
.. note:
If *key* is not present in the dict, :class:`KeyError` will be triggered.
:param key: key to be searched in the dict.
:returns: value corresponding to *key*.
"""
return self._values[key.lower()][1]
def __delitem__(self, key: str) -> None:
"""Remove *key* from this dict.
*key* is searched case-insensitively in the dict.
.. note:
If *key* is not present in the dict, :class:`KeyError` will be triggered.
:param key: key to be removed from the dict.
"""
del self._values[key.lower()]
def __iter__(self) -> Iterator[str]:
"""Iterate over keys of this dict.
:yields: each key present in the dict. Yields each key with its last case that has been stored.
"""
return iter(key for key, _ in self._values.values())
def __len__(self) -> int:
"""Get the length of this dict.
:returns: number of keys in the dict.
:Example:
>>> len(CaseInsensitiveDict({'a': 'b', 'A': 'B', 'c': 'd'}))
2
"""
return len(self._values)
def copy(self) -> 'CaseInsensitiveDict':
"""Create a copy of this dict.
:return: a new dict object with the same keys and values of this dict.
"""
return CaseInsensitiveDict({v[0]: v[1] for v in self._values.values()})
def __repr__(self) -> str:
"""Get a string representation of the dict.
Provide a helpful way of recreating the dict.
:returns: representation of the dict, showing its keys and values.
:Example:
>>> repr(CaseInsensitiveDict({'a': 'b', 'A': 'B', 'c': 'd'})) # doctest: +ELLIPSIS
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
+219 -85
View File
@@ -7,11 +7,15 @@ import yaml
from collections import defaultdict from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from patroni import PATRONI_ENV_PREFIX from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
from patroni.exceptions import ConfigParseError
from patroni.dcs import ClusterConfig from . import PATRONI_ENV_PREFIX
from patroni.postgresql.config import CaseInsensitiveDict, ConfigHandler from .collections import CaseInsensitiveDict
from patroni.utils import deep_compare, parse_bool, parse_int, patch_config from .dcs import ClusterConfig, Cluster
from .exceptions import ConfigParseError
from .file_perm import pg_perm
from .postgresql.config import ConfigHandler
from .utils import deep_compare, parse_bool, parse_int, patch_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,9 +34,129 @@ _AUTH_ALLOWED_PARAMETERS = (
) )
def default_validator(conf): def default_validator(conf: Dict[str, Any]) -> List[str]:
if not conf: if not conf:
raise ConfigParseError("Config is empty.") raise ConfigParseError("Config is empty.")
return []
class GlobalConfig(object):
"""A class that wrapps global configuration and provides convinient methods to access/check values.
It is instantiated by calling :func:`Config.global_config` method which picks either a
configuration from provided :class:`Cluster` object (the most up-to-date) or from the
local cache if :class::`ClusterConfig` is not initialized or doesn't have a valid config.
"""
def __init__(self, config: Dict[str, Any]) -> None:
"""Initialize :class:`GlobalConfig` object.
:param config: current configuration either from
:class:`ClusterConfig` or from :class:`Config.dynamic_configuration`
"""
self.__config = config
def get(self, name: str) -> Any:
"""Gets global configuration value by name.
:param name: parameter name
:returns: configuration value or `None` if it is missing
"""
return self.__config.get(name)
def check_mode(self, mode: str) -> bool:
"""Checks whether the certain parameter is enabled.
:param mode: parameter name could be: synchronous_mode, failsafe_mode, pause, check_timeline, and so on
:returns: `True` if *mode* is enabled in the global configuration.
"""
return bool(parse_bool(self.__config.get(mode)))
@property
def is_paused(self) -> bool:
""":returns: `True` if cluster is in maintenance mode."""
return self.check_mode('pause')
@property
def is_synchronous_mode(self) -> bool:
""":returns: `True` if synchronous replication is requested."""
return self.check_mode('synchronous_mode')
@property
def is_synchronous_mode_strict(self) -> bool:
""":returns: `True` if at least one synchronous node is required."""
return self.check_mode('synchronous_mode_strict')
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
""":returns: "standby_cluster" configuration."""
return deepcopy(self.get('standby_cluster'))
@property
def is_standby_cluster(self) -> bool:
""":returns: `True` if global configuration has a valid "standby_cluster" section."""
config = self.get_standby_cluster_config()
return isinstance(config, dict) and\
bool(config.get('host') or config.get('port') or config.get('restore_command'))
def get_int(self, name: str, default: int = 0) -> int:
"""Gets current value from the global configuration and trying to return it as int.
:param name: name of the parameter
:param default: default value if *name* is not in the configuration or invalid
:returns: currently configured value from the global configuration or *default* if it is not set or invalid.
"""
ret = parse_int(self.get(name))
return default if ret is None else ret
@property
def min_synchronous_nodes(self) -> int:
""":returns: the minimal number of synchronous nodes based on whether strict mode is requested or not."""
return 1 if self.is_synchronous_mode_strict else 0
@property
def synchronous_node_count(self) -> int:
""":returns: currently configured value from the global configuration or 1 if it is not set or invalid."""
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
@property
def maximum_lag_on_failover(self) -> int:
""":returns: currently configured value from the global configuration or 1048576 if it is not set or invalid."""
return self.get_int('maximum_lag_on_failover', 1048576)
@property
def maximum_lag_on_syncnode(self) -> int:
""":returns: currently configured value from the global configuration or -1 if it is not set or invalid."""
return self.get_int('maximum_lag_on_syncnode', -1)
@property
def primary_start_timeout(self) -> int:
""":returns: currently configured value from the global configuration or 300 if it is not set or invalid."""
default = 300
return self.get_int('primary_start_timeout', default)\
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
@property
def primary_stop_timeout(self) -> int:
""":returns: currently configured value from the global configuration or 300 if it is not set or invalid."""
default = 0
return self.get_int('primary_stop_timeout', default)\
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
def get_global_config(cluster: Union[Cluster, None], default: Optional[Dict[str, Any]] = None) -> GlobalConfig:
"""Instantiates :class:`GlobalConfig` based on the input.
:param cluster: the currently known cluster state from DCS
:param default: default configuration, which will be used if there is no valid *cluster.config*
:returns: :class:`GlobalConfig` object
"""
# Try to protect from the case when DCS was wiped out
if cluster and cluster.config and cluster.config.modify_version:
config = cluster.config.data
else:
config = default or {}
return GlobalConfig(deepcopy(config))
class Config(object): class Config(object):
@@ -58,21 +182,8 @@ class Config(object):
PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION' PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION'
__CACHE_FILENAME = 'patroni.dynamic.json' __CACHE_FILENAME = 'patroni.dynamic.json'
__REMAP_KEYS = { __DEFAULT_CONFIG: Dict[str, Any] = {
'master_start_timeout': 'primary_start_timeout',
'master_stop_timeout': 'primary_stop_timeout'
}
__DEFAULT_CONFIG = {
'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10, 'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10,
'maximum_lag_on_failover': 1048576,
'maximum_lag_on_syncnode': -1,
'check_timeline': False,
'primary_start_timeout': 300,
'primary_stop_timeout': 0,
'synchronous_mode': False,
'synchronous_mode_strict': False,
'synchronous_node_count': 1,
'failsafe_mode': False,
'standby_cluster': { 'standby_cluster': {
'create_replica_methods': '', 'create_replica_methods': '',
'host': '', 'host': '',
@@ -87,25 +198,24 @@ class Config(object):
'use_slots': True, 'use_slots': True,
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items() 'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()
if p not in ('wal_keep_segments', 'wal_keep_size')}) if p not in ('wal_keep_segments', 'wal_keep_size')})
},
'watchdog': {
'mode': 'automatic',
} }
} }
def __init__(self, configfile, validator=default_validator): def __init__(self, configfile: str,
self._modify_index = -1 validator: Optional[Callable[[Dict[str, Any]], List[str]]] = default_validator) -> None:
self._modify_version = -1
self._dynamic_configuration = {} self._dynamic_configuration = {}
self.__environment_configuration = self._build_environment_configuration() self.__environment_configuration = self._build_environment_configuration()
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment # Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment
self._config_file = configfile and os.path.exists(configfile) and configfile self._config_file = configfile if configfile and os.path.exists(configfile) else None
if self._config_file: if self._config_file:
self._local_configuration = self._load_config_file() self._local_configuration = self._load_config_file()
else: else:
config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None) config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None)
self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration
if validator: if validator:
errors = validator(self._local_configuration) errors = validator(self._local_configuration)
if errors: if errors:
@@ -118,17 +228,14 @@ class Config(object):
self._cache_needs_saving = False self._cache_needs_saving = False
@property @property
def config_file(self): def config_file(self) -> Union[str, None]:
return self._config_file return self._config_file
@property @property
def dynamic_configuration(self): def dynamic_configuration(self) -> Dict[str, Any]:
return deepcopy(self._dynamic_configuration) return deepcopy(self._dynamic_configuration)
def check_mode(self, mode): def _load_config_path(self, path: str) -> Dict[str, Any]:
return bool(parse_bool(self._dynamic_configuration.get(mode)))
def _load_config_path(self, path):
""" """
If path is a file, loads the yml file pointed to by path. If path is a file, loads the yml file pointed to by path.
If path is a directory, loads all yml files in that directory in alphabetical order If path is a directory, loads all yml files in that directory in alphabetical order
@@ -142,20 +249,22 @@ class Config(object):
logger.error('config path %s is neither directory nor file', path) logger.error('config path %s is neither directory nor file', path)
raise ConfigParseError('invalid config path') raise ConfigParseError('invalid config path')
overall_config = {} overall_config: Dict[str, Any] = {}
for fname in files: for fname in files:
with open(fname) as f: with open(fname) as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
patch_config(overall_config, config) patch_config(overall_config, config)
return overall_config return overall_config
def _load_config_file(self): def _load_config_file(self) -> Dict[str, Any]:
"""Loads config.yaml from filesystem and applies some values which were set via ENV""" """Loads config.yaml from filesystem and applies some values which were set via ENV"""
if TYPE_CHECKING: # pragma: no cover
assert self._config_file is not None
config = self._load_config_path(self._config_file) config = self._load_config_path(self._config_file)
patch_config(config, self.__environment_configuration) patch_config(config, self.__environment_configuration)
return config return config
def _load_cache(self): def _load_cache(self) -> None:
if os.path.isfile(self._cache_file): if os.path.isfile(self._cache_file):
try: try:
with open(self._cache_file) as f: with open(self._cache_file) as f:
@@ -163,15 +272,17 @@ class Config(object):
except Exception: except Exception:
logger.exception('Exception when loading file: %s', self._cache_file) logger.exception('Exception when loading file: %s', self._cache_file)
def save_cache(self): def save_cache(self) -> None:
if self._cache_needs_saving: if self._cache_needs_saving:
tmpfile = fd = None tmpfile = fd = None
try: try:
pg_perm.set_permissions_from_data_directory(self._data_dir)
(fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir) (fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir)
with os.fdopen(fd, 'w') as f: with os.fdopen(fd, 'w') as f:
fd = None fd = None
json.dump(self.dynamic_configuration, f) json.dump(self.dynamic_configuration, f)
tmpfile = shutil.move(tmpfile, self._cache_file) tmpfile = shutil.move(tmpfile, self._cache_file)
os.chmod(self._cache_file, pg_perm.file_create_mode)
self._cache_needs_saving = False self._cache_needs_saving = False
except Exception: except Exception:
logger.exception('Exception when saving file: %s', self._cache_file) logger.exception('Exception when saving file: %s', self._cache_file)
@@ -187,11 +298,11 @@ class Config(object):
logger.error('Can not remove temporary file %s', tmpfile) logger.error('Can not remove temporary file %s', tmpfile)
# configuration could be either ClusterConfig or dict # configuration could be either ClusterConfig or dict
def set_dynamic_configuration(self, configuration): def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool:
if isinstance(configuration, ClusterConfig): if isinstance(configuration, ClusterConfig):
if self._modify_index == configuration.modify_index: if self._modify_version == configuration.modify_version:
return False # If the index didn't changed there is nothing to do return False # If the version didn't changed there is nothing to do
self._modify_index = configuration.modify_index self._modify_version = configuration.modify_version
configuration = configuration.data configuration = configuration.data
if not deep_compare(self._dynamic_configuration, configuration): if not deep_compare(self._dynamic_configuration, configuration):
@@ -203,8 +314,9 @@ class Config(object):
return True return True
except Exception: except Exception:
logger.exception('Exception when setting dynamic_configuration') logger.exception('Exception when setting dynamic_configuration')
return False
def reload_local_configuration(self): def reload_local_configuration(self) -> Optional[bool]:
if self.config_file: if self.config_file:
try: try:
configuration = self._load_config_file() configuration = self._load_config_file()
@@ -219,18 +331,25 @@ class Config(object):
logger.exception('Exception when reloading local configuration from %s', self.config_file) logger.exception('Exception when reloading local configuration from %s', self.config_file)
@staticmethod @staticmethod
def _process_postgresql_parameters(parameters, is_local=False): def _process_postgresql_parameters(parameters: Dict[str, Any], is_local: bool = False) -> Dict[str, Any]:
return {name: value for name, value in (parameters or {}).items() pg_params: Dict[str, Any] = {}
if name not in ConfigHandler.CMDLINE_OPTIONS or
not is_local and ConfigHandler.CMDLINE_OPTIONS[name][1](value)}
def _safe_copy_dynamic_configuration(self, dynamic_configuration): for name, value in (parameters or {}).items():
if name not in ConfigHandler.CMDLINE_OPTIONS:
pg_params[name] = value
elif not is_local:
if ConfigHandler.CMDLINE_OPTIONS[name][1](value):
pg_params[name] = value
else:
logging.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
return pg_params
def _safe_copy_dynamic_configuration(self, dynamic_configuration: Dict[str, Any]) -> Dict[str, Any]:
config = deepcopy(self.__DEFAULT_CONFIG) config = deepcopy(self.__DEFAULT_CONFIG)
for name, value in dynamic_configuration.items(): for name, value in dynamic_configuration.items():
# allow copying master_start_timeout->primary_start_timeout when the latter isn't in dynamic_configuration
if name in self.__REMAP_KEYS and self.__REMAP_KEYS[name] not in dynamic_configuration:
name = self.__REMAP_KEYS[name]
if name == 'postgresql': if name == 'postgresql':
for name, value in (value or {}).items(): for name, value in (value or {}).items():
if name == 'parameters': if name == 'parameters':
@@ -243,17 +362,14 @@ class Config(object):
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 overridden 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', 'failsafe_mode'): config[name] = int(value)
config[name] = value
else:
config[name] = int(value)
return config return config
@staticmethod @staticmethod
def _build_environment_configuration(): def _build_environment_configuration() -> Dict[str, Any]:
ret = defaultdict(dict) ret: Dict[str, Any] = defaultdict(dict)
def _popenv(name): def _popenv(name: str) -> Union[str, None]:
return os.environ.pop(PATRONI_ENV_PREFIX + name.upper(), None) return os.environ.pop(PATRONI_ENV_PREFIX + name.upper(), None)
for param in ('name', 'namespace', 'scope'): for param in ('name', 'namespace', 'scope'):
@@ -261,7 +377,7 @@ class Config(object):
if value: if value:
ret[param] = value ret[param] = value
def _fix_log_env(name, oldname): def _fix_log_env(name: str, oldname: str) -> None:
value = _popenv(oldname) value = _popenv(oldname)
name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper() name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper()
if value and name not in os.environ: if value and name not in os.environ:
@@ -270,7 +386,7 @@ class Config(object):
for name, oldname in (('level', 'loglevel'), ('format', 'logformat'), ('dateformat', 'log_datefmt')): for name, oldname in (('level', 'loglevel'), ('format', 'logformat'), ('dateformat', 'log_datefmt')):
_fix_log_env(name, oldname) _fix_log_env(name, oldname)
def _set_section_values(section, params): def _set_section_values(section: str, params: List[str]) -> None:
for param in params: for param in params:
value = _popenv(section + '_' + param) value = _popenv(section + '_' + param)
if value: if value:
@@ -278,7 +394,8 @@ class Config(object):
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password', _set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
'cafile', 'ciphers', 'verify_client', 'http_extra_headers', 'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
'https_extra_headers', 'allowlist', 'allowlist_include_members']) 'https_extra_headers', 'allowlist', 'allowlist_include_members',
'request_queue_size'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password']) _set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address', _set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
'config_dir', 'data_dir', 'pgpass', 'bin_dir']) 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
@@ -286,6 +403,11 @@ class Config(object):
'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']) _set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
value = _popenv('POSTGRESQL_BIN_' + binary)
if value:
ret['postgresql'].setdefault('bin_name', {})[binary] = value
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')): for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
value = ret.get(first, {}).pop(second, None) value = ret.get(first, {}).pop(second, None)
if value: if value:
@@ -293,14 +415,16 @@ class Config(object):
if value is not None: if value is not None:
ret[first][second] = value ret[first][second] = value
for second in ('max_queue_size', 'file_size', 'file_num'): for first, params in (('restapi', ('request_queue_size',)),
value = ret.get('log', {}).pop(second, None) ('log', ('max_queue_size', 'file_size', 'file_num'))):
if value: for second in params:
value = parse_int(value) value = ret.get(first, {}).pop(second, None)
if value is not None: if value:
ret['log'][second] = value value = parse_int(value)
if value is not None:
ret[first][second] = value
def _parse_list(value): def _parse_list(value: str) -> Union[List[str], None]:
if not (value.strip().startswith('-') or '[' in value): if not (value.strip().startswith('-') or '[' in value):
value = '[{0}]'.format(value) value = '[{0}]'.format(value)
try: try:
@@ -316,7 +440,7 @@ class Config(object):
if value: if value:
ret[first][second] = value ret[first][second] = value
def _parse_dict(value): def _parse_dict(value: str) -> Union[Dict[str, Any], None]:
if not value.strip().startswith('{'): if not value.strip().startswith('{'):
value = '{{{0}}}'.format(value) value = '{{{0}}}'.format(value)
try: try:
@@ -333,17 +457,18 @@ class Config(object):
if value: if value:
ret[first][second] = value ret[first][second] = value
def _get_auth(name, params=None): def _get_auth(name: str, params: Optional[Collection[str]] = None) -> Dict[str, str]:
ret = {} ret: Dict[str, str] = {}
for param in params or _AUTH_ALLOWED_PARAMETERS[:2]: for param in params or _AUTH_ALLOWED_PARAMETERS[:2]:
value = _popenv(name + '_' + param) value = _popenv(name + '_' + param)
if value: if value:
ret[param] = value ret[param] = value
return ret return ret
restapi_auth = _get_auth('restapi') for section in ('ctl', 'restapi'):
if restapi_auth: auth = _get_auth(section)
ret['restapi']['authentication'] = restapi_auth if auth:
ret[section]['authentication'] = auth
authentication = {} authentication = {}
for user_type in ('replication', 'superuser', 'rewind'): for user_type in ('replication', 'superuser', 'rewind'):
@@ -363,7 +488,8 @@ class Config(object):
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME', 'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD', 'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE') and name: 'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
value = os.environ.pop(param) value = os.environ.pop(param)
if name == 'CITUS': if name == 'CITUS':
if suffix == 'GROUP': if suffix == 'GROUP':
@@ -403,7 +529,8 @@ class Config(object):
return ret return ret
def _build_effective_configuration(self, dynamic_configuration, local_configuration): def _build_effective_configuration(self, dynamic_configuration: Dict[str, Any],
local_configuration: Dict[str, Union[Dict[str, Any], Any]]) -> Dict[str, Any]:
config = self._safe_copy_dynamic_configuration(dynamic_configuration) config = self._safe_copy_dynamic_configuration(dynamic_configuration)
for name, value in local_configuration.items(): for name, value in local_configuration.items():
if name == 'citus': # remove invalid citus configuration if name == 'citus': # remove invalid citus configuration
@@ -419,9 +546,10 @@ class Config(object):
elif name not in config or name in ['watchdog']: elif name not in config or name in ['watchdog']:
config[name] = deepcopy(value) if value else {} config[name] = deepcopy(value) if value else {}
# restapi server expects to get restapi.auth = 'username:password' # restapi server expects to get restapi.auth = 'username:password' and similarly for `ctl`
if 'restapi' in config and 'authentication' in config['restapi']: for section in ('ctl', 'restapi'):
config['restapi']['auth'] = '{username}:{password}'.format(**config['restapi']['authentication']) if section in config and 'authentication' in config[section]:
config[section]['auth'] = '{username}:{password}'.format(**config[section]['authentication'])
# special treatment for old config # special treatment for old config
@@ -458,10 +586,6 @@ class Config(object):
'name', 'name',
'scope', 'scope',
'retry_timeout', 'retry_timeout',
'synchronous_mode',
'synchronous_mode_strict',
'synchronous_node_count',
'maximum_lag_on_syncnode',
'citus' 'citus'
) )
@@ -469,14 +593,24 @@ class Config(object):
return config return config
def get(self, key, default=None): def get(self, key: str, default: Optional[Any] = None) -> Any:
return self.__effective_configuration.get(key, default) return self.__effective_configuration.get(key, default)
def __contains__(self, key): def __contains__(self, key: str) -> bool:
return key in self.__effective_configuration return key in self.__effective_configuration
def __getitem__(self, key): def __getitem__(self, key: str) -> Any:
return self.__effective_configuration[key] return self.__effective_configuration[key]
def copy(self): def copy(self) -> Dict[str, Any]:
return deepcopy(self.__effective_configuration) return deepcopy(self.__effective_configuration)
def get_global_config(self, cluster: Union[Cluster, None]) -> GlobalConfig:
"""Instantiate :class:`GlobalConfig` based on input.
Use the configuration from provided *cluster* (the most up-to-date) or from the
local cache if *cluster.config* is not initialized or doesn't have a valid config.
:param cluster: the currently known cluster state from DCS
:returns: :class:`GlobalConfig` object
"""
return get_global_config(cluster, self._dynamic_configuration)
+1091 -199
View File
File diff suppressed because it is too large Load Diff
+26 -33
View File
@@ -6,15 +6,32 @@ Currently it is only used for the main "Thread" of ``patroni`` and ``patroni_raf
from __future__ import print_function from __future__ import print_function
import abc import abc
import argparse
import os import os
import signal import signal
import sys import sys
from threading import Lock from threading import Lock
from typing import Any, Optional, Type from typing import Any, Optional, Type, TYPE_CHECKING
from .config import Config if TYPE_CHECKING: # pragma: no cover
from .validator import Schema from .config import Config
def get_base_arg_parser() -> argparse.ArgumentParser:
"""Create a basic argument parser with the arguments used for both patroni and raft controller daemon.
:returns: 'argparse.ArgumentParser' object
"""
from .config import Config
from .version import __version__
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
return parser
class AbstractPatroniDaemon(abc.ABC): class AbstractPatroniDaemon(abc.ABC):
@@ -30,7 +47,7 @@ class AbstractPatroniDaemon(abc.ABC):
:ivar config: configuration options for this daemon. :ivar config: configuration options for this daemon.
""" """
def __init__(self, config: Config) -> None: def __init__(self, config: 'Config') -> None:
"""Set up signal handlers, logging handler and configuration. """Set up signal handlers, logging handler and configuration.
:param config: configuration options for this daemon. :param config: configuration options for this daemon.
@@ -94,7 +111,7 @@ class AbstractPatroniDaemon(abc.ABC):
with self._sigterm_lock: with self._sigterm_lock:
return self._received_sigterm return self._received_sigterm
def reload_config(self, sighup: Optional[bool] = False, local: Optional[bool] = False) -> None: def reload_config(self, sighup: bool = False, local: Optional[bool] = False) -> None:
"""Reload configuration. """Reload configuration.
:param sighup: if it is related to a SIGHUP signal. :param sighup: if it is related to a SIGHUP signal.
@@ -140,41 +157,17 @@ class AbstractPatroniDaemon(abc.ABC):
self.logger.shutdown() self.logger.shutdown()
def abstract_main(cls: Type[AbstractPatroniDaemon], validator: Optional[Schema] = None) -> None: def abstract_main(cls: Type[AbstractPatroniDaemon], configfile: str) -> None:
"""Create the main entry point of a given daemon process. """Create the main entry point of a given daemon process.
Expose a basic argument parser, parse the command-line arguments, and run the given daemon process.
:param cls: a class that should inherit from :class:`AbstractPatroniDaemon`. :param cls: a class that should inherit from :class:`AbstractPatroniDaemon`.
:param validator: used to validate the daemon configuration schema, if requested by the user through :param configfile:
``--validate-config`` CLI option.
""" """
import argparse
from .config import Config, ConfigParseError from .config import Config, ConfigParseError
from .version import __version__
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
if validator:
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
args = parser.parse_args()
validate_config = validator and args.validate_config
try: try:
if validate_config: config = Config(configfile)
Config(args.configfile, validator=validator)
sys.exit()
config = Config(args.configfile)
except ConfigParseError as e: except ConfigParseError as e:
if e.value: sys.exit(e.value)
print(e.value, file=sys.stderr)
if not validate_config:
parser.print_help()
sys.exit(1)
controller = cls(config) controller = cls(config)
try: try:
+549 -300
View File
File diff suppressed because it is too large Load Diff
+137 -114
View File
@@ -8,16 +8,19 @@ import ssl
import time import time
import urllib3 import urllib3
from collections import defaultdict, namedtuple from collections import defaultdict
from consul import ConsulException, NotFound, base from consul import ConsulException, NotFound, base
from http.client import HTTPException from http.client import HTTPException
from urllib3.exceptions import HTTPError from urllib3.exceptions import HTTPError
from urllib.parse import urlencode, urlparse, quote from urllib.parse import urlencode, urlparse, quote
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\ from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from ..exceptions import DCSError from ..exceptions import DCSError
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,12 +41,17 @@ class InvalidSession(ConsulException):
"""invalid session""" """invalid session"""
Response = namedtuple('Response', 'code,headers,body,content') class Response(NamedTuple):
code: int
headers: Union[Mapping[str, str], Mapping[bytes, bytes], None]
body: str
content: bytes
class HTTPClient(object): class HTTPClient(object):
def __init__(self, host='127.0.0.1', port=8500, token=None, scheme='http', verify=True, cert=None, ca_cert=None): def __init__(self, host: str = '127.0.0.1', port: int = 8500, token: Optional[str] = None, scheme: str = 'http',
verify: bool = True, cert: Optional[str] = None, ca_cert: Optional[str] = None) -> None:
self.token = token self.token = token
self._read_timeout = 10 self._read_timeout = 10
self.base_uri = uri(scheme, (host, port)) self.base_uri = uri(scheme, (host, port))
@@ -59,23 +67,23 @@ class HTTPClient(object):
if ca_cert: if ca_cert:
kwargs['ca_certs'] = ca_cert kwargs['ca_certs'] = ca_cert
kwargs['cert_reqs'] = ssl.CERT_REQUIRED if verify or ca_cert else ssl.CERT_NONE kwargs['cert_reqs'] = ssl.CERT_REQUIRED if verify or ca_cert else ssl.CERT_NONE
self.http = urllib3.PoolManager(num_pools=10, maxsize=10, **kwargs) self.http = urllib3.PoolManager(num_pools=10, maxsize=10, headers={}, **kwargs)
self._ttl = None self._ttl = 30
def set_read_timeout(self, timeout): def set_read_timeout(self, timeout: float) -> None:
self._read_timeout = timeout/3.0 self._read_timeout = timeout / 3.0
@property @property
def ttl(self): def ttl(self) -> int:
return self._ttl return self._ttl
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> bool:
ret = self._ttl != ttl ret = self._ttl != ttl
self._ttl = ttl self._ttl = ttl
return ret return ret
@staticmethod @staticmethod
def response(response): def response(response: urllib3.response.HTTPResponse) -> Response:
content = response.data content = response.data
body = content.decode('utf-8') body = content.decode('utf-8')
if response.status == 500: if response.status == 500:
@@ -88,14 +96,19 @@ class HTTPClient(object):
raise ConsulInternalError(msg) raise ConsulInternalError(msg)
return Response(response.status, response.headers, body, content) return Response(response.status, response.headers, body, content)
def uri(self, path, params=None): def uri(self, path: str,
params: Union[None, Dict[str, Any], List[Tuple[str, Any]], Tuple[Tuple[str, Any], ...]] = None) -> str:
return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '') return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '')
def __getattr__(self, method): def __getattr__(self, method: str) -> Callable[[Callable[[Response], Union[bool, Any, Tuple[str, Any]]],
str, Union[None, Dict[str, Any], List[Tuple[str, Any]]],
str, Optional[Dict[str, str]]], Union[bool, Any, Tuple[str, Any]]]:
if method not in ('get', 'post', 'put', 'delete'): if method not in ('get', 'post', 'put', 'delete'):
raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method)) raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method))
def wrapper(callback, path, params=None, data='', headers=None): def wrapper(callback: Callable[[Response], Union[bool, Any, Tuple[str, Any]]], path: str,
params: Union[None, Dict[str, Any], List[Tuple[str, Any]]] = None, data: str = '',
headers: Optional[Dict[str, str]] = None) -> Union[bool, Any, Tuple[str, Any]]:
# python-consul doesn't allow to specify ttl smaller then 10 seconds # python-consul doesn't allow to specify ttl smaller then 10 seconds
# because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack... # because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack...
if method == 'put' and path == '/v1/session/create': if method == 'put' and path == '/v1/session/create':
@@ -106,14 +119,14 @@ class HTTPClient(object):
data = data[:-1] + ', ' + ttl + '}' data = data[:-1] + ', ' + ttl + '}'
if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params
params = {k: v for k, v in params} params = {k: v for k, v in params}
kwargs = {'retries': 0, 'preload_content': False, 'body': data} kwargs: Dict[str, Any] = {'retries': 0, 'preload_content': False, 'body': data}
if method == 'get' and isinstance(params, dict) and 'index' in params: if method == 'get' and isinstance(params, dict) and 'index' in params:
timeout = float(params['wait'][:-1]) if 'wait' in params else 300 timeout = float(params['wait'][:-1]) if 'wait' in params else 300
# 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 slightly 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
kwargs['headers'] = (headers or {}).copy() kwargs['headers'] = (headers or {}).copy()
@@ -127,13 +140,13 @@ class HTTPClient(object):
class ConsulClient(base.Consul): class ConsulClient(base.Consul):
def __init__(self, *args, **kwargs): def __init__(self, *args: Any, **kwargs: Any) -> None:
self._cert = kwargs.pop('cert', None) self._cert = kwargs.pop('cert', None)
self._ca_cert = kwargs.pop('ca_cert', None) self._ca_cert = kwargs.pop('ca_cert', None)
self.token = kwargs.get('token') self.token = kwargs.get('token')
super(ConsulClient, self).__init__(*args, **kwargs) super(ConsulClient, self).__init__(*args, **kwargs)
def http_connect(self, *args, **kwargs): def http_connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args))) kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args)))
if self._cert: if self._cert:
kwargs['cert'] = self._cert kwargs['cert'] = self._cert
@@ -143,17 +156,17 @@ class ConsulClient(base.Consul):
kwargs['token'] = self.token kwargs['token'] = self.token
return HTTPClient(**kwargs) return HTTPClient(**kwargs)
def connect(self, *args, **kwargs): def connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
return self.http_connect(*args, **kwargs) return self.http_connect(*args, **kwargs)
def reload_config(self, config): def reload_config(self, config: Dict[str, Any]) -> None:
self.http.token = self.token = config.get('token') self.http.token = self.token = config.get('token')
self.consistency = config.get('consistency', 'default') self.consistency = config.get('consistency', 'default')
self.dc = config.get('dc') self.dc = config.get('dc')
def catch_consul_errors(func): def catch_consul_errors(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout): except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
@@ -161,24 +174,25 @@ def catch_consul_errors(func):
return wrapper return wrapper
def force_if_last_failed(func): def force_if_last_failed(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
if wrapper.last_result is False: if getattr(wrapper, 'last_result', None) is False:
kwargs['force'] = True kwargs['force'] = True
wrapper.last_result = func(*args, **kwargs) last_result = func(*args, **kwargs)
return wrapper.last_result setattr(wrapper, 'last_result', last_result)
return last_result
wrapper.last_result = None setattr(wrapper, 'last_result', None)
return wrapper return wrapper
def service_name_from_scope_name(scope_name): def service_name_from_scope_name(scope_name: str) -> str:
"""Translate scope name to service name which can be used in dns. """Translate scope name to service name which can be used in dns.
230 = 253 - len('replica.') - len('.service.consul') 230 = 253 - len('replica.') - len('.service.consul')
""" """
def replace_char(match): def replace_char(match: Any) -> str:
c = match.group(0) c = match.group(0)
return '-' if c in '. _' else "u{:04d}".format(ord(c)) return '-' if c in '. _' else "u{:04d}".format(ord(c))
@@ -188,7 +202,7 @@ def service_name_from_scope_name(scope_name):
class Consul(AbstractDCS): class Consul(AbstractDCS):
def __init__(self, config): def __init__(self, config: Dict[str, Any]) -> None:
super(Consul, self).__init__(config) super(Consul, self).__init__(config)
self._base_path = self._base_path[1:] self._base_path = self._base_path[1:]
self._scope = config['scope'] self._scope = config['scope']
@@ -198,9 +212,9 @@ class Consul(AbstractDCS):
retry_exceptions=(ConsulInternalError, HTTPException, retry_exceptions=(ConsulInternalError, HTTPException,
HTTPError, socket.error, socket.timeout)) HTTPError, socket.error, socket.timeout))
kwargs = {}
if 'url' in config: if 'url' in config:
r = urlparse(config['url']) url: str = config['url']
r = urlparse(url)
config.update({'scheme': r.scheme, 'host': r.hostname, 'port': r.port or 8500}) config.update({'scheme': r.scheme, 'host': r.hostname, 'port': r.port or 8500})
elif 'host' in config: elif 'host' in config:
host, port = split_host_port(config.get('host', '127.0.0.1:8500'), 8500) host, port = split_host_port(config.get('host', '127.0.0.1:8500'), 8500)
@@ -215,7 +229,7 @@ class Consul(AbstractDCS):
config['cert'] = (config['cert'], config['key']) config['cert'] = (config['cert'], config['key'])
config_keys = ('host', 'port', 'token', 'scheme', 'cert', 'ca_cert', 'dc', 'consistency') config_keys = ('host', 'port', 'token', 'scheme', 'cert', 'ca_cert', 'dc', 'consistency')
kwargs = {p: config.get(p) for p in config_keys if config.get(p)} kwargs: Dict[str, Any] = {p: config.get(p) for p in config_keys if config.get(p)}
verify = config.get('verify') verify = config.get('verify')
if not isinstance(verify, bool): if not isinstance(verify, bool):
@@ -240,10 +254,10 @@ class Consul(AbstractDCS):
self.create_session() self.create_session()
self._previous_loop_token = self._client.token self._previous_loop_token = self._client.token
def retry(self, *args, **kwargs): def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
return self._retry.copy()(*args, **kwargs) return self._retry.copy()(method, *args, **kwargs)
def create_session(self): def create_session(self) -> None:
while not self._session: while not self._session:
try: try:
self.refresh_session() self.refresh_session()
@@ -251,13 +265,14 @@ class Consul(AbstractDCS):
logger.info('waiting on consul') logger.info('waiting on consul')
time.sleep(5) time.sleep(5)
def reload_config(self, config): def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
super(Consul, self).reload_config(config) super(Consul, self).reload_config(config)
consul_config = config.get('consul', {}) consul_config = config.get('consul', {})
self._client.reload_config(consul_config) self._client.reload_config(consul_config)
self._previous_loop_service_tags = self._service_tags self._previous_loop_service_tags = self._service_tags
self._service_tags = sorted(consul_config.get('service_tags', [])) self._service_tags: List[str] = consul_config.get('service_tags', [])
self._service_tags.sort()
should_register_service = consul_config.get('register_service', False) should_register_service = consul_config.get('register_service', False)
if should_register_service and not self._register_service: if should_register_service and not self._register_service:
@@ -266,29 +281,30 @@ class Consul(AbstractDCS):
self._previous_loop_register_service = self._register_service self._previous_loop_register_service = self._register_service
self._register_service = should_register_service self._register_service = should_register_service
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x if self._client.http.set_ttl(ttl / 2.0): # Consul multiplies the TTL by 2x
self._session = None self._session = None
self.__do_not_watch = True self.__do_not_watch = True
return None
@property @property
def ttl(self): def ttl(self) -> int:
return self._client.http.ttl * 2 # we multiply the value by 2 because it was divided in the `set_ttl()` method return self._client.http.ttl * 2 # we multiply the value by 2 because it was divided in the `set_ttl()` method
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self._retry.deadline = retry_timeout self._retry.deadline = retry_timeout
self._client.http.set_read_timeout(retry_timeout) self._client.http.set_read_timeout(retry_timeout)
def adjust_ttl(self): def adjust_ttl(self) -> None:
try: try:
settings = self._client.agent.self() settings = self._client.agent.self()
min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000)/1000000000.0 min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000) / 1000000000.0
logger.warning('Changing Session TTL from %s to %s', self._client.http.ttl, min_ttl) logger.warning('Changing Session TTL from %s to %s', self._client.http.ttl, min_ttl)
self._client.http.set_ttl(min_ttl) self._client.http.set_ttl(min_ttl)
except Exception: except Exception:
logger.exception('adjust_ttl') logger.exception('adjust_ttl')
def _do_refresh_session(self, force=False): def _do_refresh_session(self, force: bool = False) -> bool:
""":returns: `!True` if it had to create new session""" """:returns: `!True` if it had to create new session"""
if not force and self._session and self._last_session_refresh + self._loop_wait > time.time(): if not force and self._session and self._last_session_refresh + self._loop_wait > time.time():
return False return False
@@ -312,7 +328,7 @@ class Consul(AbstractDCS):
self._last_session_refresh = time.time() self._last_session_refresh = time.time()
return ret return ret
def refresh_session(self): def refresh_session(self) -> bool:
try: try:
return self.retry(self._do_refresh_session) return self.retry(self._do_refresh_session)
except (ConsulException, RetryFailedError): except (ConsulException, RetryFailedError):
@@ -320,10 +336,10 @@ class Consul(AbstractDCS):
raise ConsulError('Failed to renew/create session') raise ConsulError('Failed to renew/create session')
@staticmethod @staticmethod
def member(node): def member(node: Dict[str, str]) -> Member:
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value']) return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
def _cluster_from_nodes(self, nodes): def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['Value'] initialize = initialize and initialize['Value']
@@ -351,7 +367,7 @@ class Consul(AbstractDCS):
slots = None slots = None
try: try:
last_lsn = int(last_lsn) last_lsn = int(last_lsn or '')
except Exception: except Exception:
last_lsn = 0 last_lsn = 0
@@ -384,8 +400,12 @@ class Consul(AbstractDCS):
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _cluster_loader(self, path): @property
_, results = self.retry(self._client.kv.get, path, recurse=True) def _consistency(self) -> str:
return 'consistent' if self._ctl else self._client.consistency
def _cluster_loader(self, path: str) -> Cluster:
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
if results is None: if results is None:
raise NotFound raise NotFound
nodes = {} nodes = {}
@@ -395,9 +415,9 @@ class Consul(AbstractDCS):
return self._cluster_from_nodes(nodes) return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path): def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
_, results = self.retry(self._client.kv.get, path, recurse=True) _, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
clusters = defaultdict(dict) clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
for node in results or []: for node in results or []:
key = node['Key'][len(path):].split('/', 1) key = node['Key'][len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]): if len(key) == 2 and citus_group_re.match(key[0]):
@@ -405,7 +425,9 @@ class Consul(AbstractDCS):
clusters[int(key[0])][key[1]] = node clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
try: try:
return loader(path) return loader(path)
except NotFound: except NotFound:
@@ -415,7 +437,7 @@ class Consul(AbstractDCS):
raise ConsulError('Consul is not responding properly') raise ConsulError('Consul is not responding properly')
@catch_consul_errors @catch_consul_errors
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
cluster = self.cluster cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False) member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
@@ -447,30 +469,32 @@ class Consul(AbstractDCS):
logger.exception('touch_member') logger.exception('touch_member')
return False return False
def _set_service_name(self): def _set_service_name(self) -> None:
self._service_name = service_name_from_scope_name(self._scope) self._service_name = service_name_from_scope_name(self._scope)
if self._scope != self._service_name: if self._scope != self._service_name:
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope) logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope)
@catch_consul_errors @catch_consul_errors
def register_service(self, service_name, **kwargs): def register_service(self, service_name: str, **kwargs: Any) -> bool:
logger.info('Register service %s, params %s', service_name, kwargs) logger.info('Register service %s, params %s', service_name, kwargs)
return self._client.agent.service.register(service_name, **kwargs) return self._client.agent.service.register(service_name, **kwargs)
@catch_consul_errors @catch_consul_errors
def deregister_service(self, service_id): def deregister_service(self, service_id: str) -> bool:
logger.info('Deregister service %s', service_id) logger.info('Deregister service %s', service_id)
# service_id can contain special characters, but is used as part of uri in deregister request # service_id can contain special characters, but is used as part of uri in deregister request
service_id = quote(service_id) service_id = quote(service_id)
return self._client.agent.service.deregister(service_id) return self._client.agent.service.deregister(service_id)
def _update_service(self, data): def _update_service(self, data: Dict[str, Any]) -> Optional[bool]:
service_name = self._service_name service_name = self._service_name
role = data['role'].replace('_', '-') role = data['role'].replace('_', '-')
state = data['state'] state = data['state']
api_parts = urlparse(data['api_url']) api_url: str = data['api_url']
api_parts = urlparse(api_url)
api_parts = api_parts._replace(path='/{0}'.format(role)) api_parts = api_parts._replace(path='/{0}'.format(role))
conn_parts = urlparse(data['conn_url']) conn_url: str = data['conn_url']
conn_parts = urlparse(conn_url)
check = base.Check.http(api_parts.geturl(), self._service_check_interval, check = base.Check.http(api_parts.geturl(), self._service_check_interval,
deregister='{0}s'.format(self._client.http.ttl * 10)) deregister='{0}s'.format(self._client.http.ttl * 10))
if self._service_check_tls_server_name is not None: if self._service_check_tls_server_name is not None:
@@ -506,7 +530,7 @@ class Consul(AbstractDCS):
logger.warning('Could not register service: unknown role type %s', role) logger.warning('Could not register service: unknown role type %s', role)
@force_if_last_failed @force_if_last_failed
def update_service(self, old_data, new_data, force=False): def update_service(self, old_data: Dict[str, Any], new_data: Dict[str, Any], force: bool = False) -> Optional[bool]:
update = False update = False
for key in ['role', 'api_url', 'conn_url', 'state']: for key in ['role', 'api_url', 'conn_url', 'state']:
@@ -523,30 +547,26 @@ class Consul(AbstractDCS):
): ):
return self._update_service(new_data) return self._update_service(new_data)
def _do_attempt_to_acquire_leader(self, retry): def _do_attempt_to_acquire_leader(self, retry: Retry) -> bool:
try: try:
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session) return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
except InvalidSession: except InvalidSession:
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt') logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
self._session = None self._session = None
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(0)
retry(self._do_refresh_session) retry(self._do_refresh_session)
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
if retry.deadline < 1:
raise ConsulError('_do_attempt_to_acquire_leader timeout')
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session) return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
@catch_return_false_exception @catch_return_false_exception
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
retry = self._retry.copy() retry = self._retry.copy()
self._run_and_handle_exceptions(self._do_refresh_session, retry=retry) self._run_and_handle_exceptions(self._do_refresh_session, retry=retry)
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(1, ConsulError('attempt_to_acquire_leader timeout'))
if retry.deadline < 1:
raise ConsulError('attempt_to_acquire_leader timeout')
ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None) ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None)
if not ret: if not ret:
@@ -554,31 +574,31 @@ class Consul(AbstractDCS):
return ret return ret
def take_leader(self): def take_leader(self) -> bool:
return self.attempt_to_acquire_leader() return self.attempt_to_acquire_leader()
@catch_consul_errors @catch_consul_errors
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.kv.put(self.failover_path, value, cas=index) return self._client.kv.put(self.failover_path, value, cas=version)
@catch_consul_errors @catch_consul_errors
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.kv.put(self.config_path, value, cas=index) return self._client.kv.put(self.config_path, value, cas=version)
@catch_consul_errors @catch_consul_errors
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._client.kv.put(self.leader_optime_path, last_lsn) return self._client.kv.put(self.leader_optime_path, last_lsn)
@catch_consul_errors @catch_consul_errors
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._client.kv.put(self.status_path, value) return self._client.kv.put(self.status_path, value)
@catch_consul_errors @catch_consul_errors
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._client.kv.put(self.failsafe_path, value) return self._client.kv.put(self.failsafe_path, value)
@staticmethod @staticmethod
def _run_and_handle_exceptions(method, *args, **kwargs): def _run_and_handle_exceptions(method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
retry = kwargs.pop('retry', None) retry = kwargs.pop('retry', None)
try: try:
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs) return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
@@ -588,73 +608,76 @@ class Consul(AbstractDCS):
raise ReturnFalseException raise ReturnFalseException
@catch_return_false_exception @catch_return_false_exception
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
retry = self._retry.copy() retry = self._retry.copy()
self._run_and_handle_exceptions(self._do_refresh_session, True, retry=retry) self._run_and_handle_exceptions(self._do_refresh_session, True, retry=retry)
if self._session: if self._session and leader.session != self._session:
cluster = self.cluster retry.ensure_deadline(1, ConsulError('update_leader timeout'))
leader_session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
if leader_session != self._session:
retry.deadline = retry.stoptime - time.time()
if retry.deadline < 1:
raise ConsulError('update_leader timeout')
logger.warning('Recreating the leader key due to session mismatch')
if cluster.leader:
self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=cluster.leader.index)
retry.deadline = retry.stoptime - time.time() logger.warning('Recreating the leader key due to session mismatch')
if retry.deadline < 0.5: self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=leader.version)
raise ConsulError('update_leader timeout')
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path, retry.ensure_deadline(0.5, ConsulError('update_leader timeout'))
self._name, acquire=self._session)
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
return bool(self._session) return bool(self._session)
@catch_consul_errors @catch_consul_errors
def initialize(self, create_new=True, sysid=''): def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
kwargs = {'cas': 0} if create_new else {} kwargs = {'cas': 0} if create_new else {}
return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs) return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs)
@catch_consul_errors @catch_consul_errors
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self.retry(self._client.kv.delete, self.initialize_path) return self.retry(self._client.kv.delete, self.initialize_path)
@catch_consul_errors @catch_consul_errors
def delete_cluster(self): def delete_cluster(self) -> bool:
return self.retry(self._client.kv.delete, self.client_path(''), recurse=True) return self.retry(self._client.kv.delete, self.client_path(''), recurse=True)
@catch_consul_errors @catch_consul_errors
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._client.kv.put(self.history_path, value) return self._client.kv.put(self.history_path, value)
@catch_consul_errors @catch_consul_errors
def _delete_leader(self): def _delete_leader(self) -> bool:
cluster = self.cluster cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name: if cluster and isinstance(cluster.leader, Leader) and\
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index) cluster.leader.name == self._name and isinstance(cluster.leader.version, int):
return self._client.kv.delete(self.leader_path, cas=cluster.leader.version)
return True
@catch_consul_errors @catch_consul_errors
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self.retry(self._client.kv.put, self.sync_path, value, cas=index) retry = self._retry.copy()
ret = retry(self._client.kv.put, self.sync_path, value, cas=version)
if ret: # We have no other choise, only read after write :(
if not retry.ensure_deadline(0.5):
return False
_, ret = self.retry(self._client.kv.get, self.sync_path)
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
return ret['ModifyIndex']
return False
@catch_consul_errors @catch_consul_errors
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self.retry(self._client.kv.delete, self.sync_path, cas=index) return self.retry(self._client.kv.delete, self.sync_path, cas=version)
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
self._last_session_refresh = 0 self._last_session_refresh = 0
if self.__do_not_watch: if self.__do_not_watch:
self.__do_not_watch = False self.__do_not_watch = False
return True return True
if leader_index: if leader_version:
end_time = time.time() + timeout end_time = time.time() + timeout
while timeout >= 1: while timeout >= 1:
try: try:
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's') idx, _ = self._client.kv.get(self.leader_path, index=leader_version, wait=str(timeout) + 's')
return str(idx) != str(leader_index) return str(idx) != str(leader_version)
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout): except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
logger.exception('watch') logger.exception('watch')
+176 -133
View File
@@ -16,16 +16,18 @@ from dns import resolver
from http.client import HTTPException from http.client import HTTPException
from queue import Queue from queue import Queue
from threading import Thread from threading import Thread
from typing import List, Optional from typing import Any, Callable, Collection, Dict, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib3 import Timeout from urllib3 import Timeout
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\ from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from ..exceptions import DCSError from ..exceptions import DCSError
from ..request import get as requests_get from ..request import get as requests_get
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,18 +40,21 @@ class EtcdError(DCSError):
pass pass
_AddrInfo = Tuple[socket.AddressFamily, socket.SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]]
class DnsCachingResolver(Thread): class DnsCachingResolver(Thread):
def __init__(self, cache_time=600.0, cache_fail_time=30.0): def __init__(self, cache_time: float = 600.0, cache_fail_time: float = 30.0) -> None:
super(DnsCachingResolver, self).__init__() super(DnsCachingResolver, self).__init__()
self._cache = {} self._cache: Dict[Tuple[str, int], Tuple[float, List[_AddrInfo]]] = {}
self._cache_time = cache_time self._cache_time = cache_time
self._cache_fail_time = cache_fail_time self._cache_fail_time = cache_fail_time
self._resolve_queue = Queue() self._resolve_queue: Queue[Tuple[Tuple[str, int], int]] = Queue()
self.daemon = True self.daemon = True
self.start() self.start()
def run(self): def run(self) -> None:
while True: while True:
(host, port), attempt = self._resolve_queue.get() (host, port), attempt = self._resolve_queue.get()
response = self._do_resolve(host, port) response = self._do_resolve(host, port)
@@ -60,7 +65,7 @@ class DnsCachingResolver(Thread):
self.resolve_async(host, port, attempt + 1) self.resolve_async(host, port, attempt + 1)
time.sleep(1) time.sleep(1)
def resolve(self, host, port): def resolve(self, host: str, port: int) -> List[_AddrInfo]:
current_time = time.time() current_time = time.time()
cached_time, response = self._cache.get((host, port), (0, [])) cached_time, response = self._cache.get((host, port), (0, []))
time_passed = current_time - cached_time time_passed = current_time - cached_time
@@ -71,14 +76,14 @@ class DnsCachingResolver(Thread):
response = new_response response = new_response
return response return response
def resolve_async(self, host, port, attempt=0): def resolve_async(self, host: str, port: int, attempt: int = 0) -> None:
self._resolve_queue.put(((host, port), attempt)) self._resolve_queue.put(((host, port), attempt))
def remove(self, host, port): def remove(self, host: str, port: int) -> None:
self._cache.pop((host, port), None) self._cache.pop((host, port), None)
@staticmethod @staticmethod
def _do_resolve(host, port): def _do_resolve(host: str, port: int) -> List[_AddrInfo]:
try: try:
return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP) return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
except Exception as e: except Exception as e:
@@ -88,13 +93,15 @@ class DnsCachingResolver(Thread):
class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client): class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
def __init__(self, config, dns_resolver, cache_ttl=300): ERROR_CLS: Type[Exception]
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
self._dns_resolver = dns_resolver self._dns_resolver = dns_resolver
self.set_machines_cache_ttl(cache_ttl) self.set_machines_cache_ttl(cache_ttl)
self._machines_cache_updated = 0 self._machines_cache_updated = 0
args = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'username', 'password', kwargs = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'version_prefix',
'cert', 'ca_cert') if config.get(p)} 'username', 'password', 'cert', 'ca_cert') if config.get(p)}
super(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **args) super(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **kwargs)
# For some reason python3-etcd on debian and ubuntu are not based on the latest version # For some reason python3-etcd on debian and ubuntu are not based on the latest version
# Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied # Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied
self.http.connection_pool_kw.pop('ssl_version', None) self.http.connection_pool_kw.pop('ssl_version', None)
@@ -106,7 +113,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
self._read_options.add('retry') self._read_options.add('retry')
self._del_conditions.add('retry') self._del_conditions.add('retry')
def _calculate_timeouts(self, etcd_nodes, timeout=None): def _calculate_timeouts(self, etcd_nodes: int, timeout: Optional[float] = None) -> Tuple[int, float, int]:
"""Calculate a request timeout and number of retries per single etcd node. """Calculate a request timeout and number of retries per single etcd node.
In case if the timeout per node is too small (less than one second) we will reduce the number of nodes. In case if the timeout per node is too small (less than one second) we will reduce the number of nodes.
For the cluster with only one node we will try to do 2 retries. For the cluster with only one node we will try to do 2 retries.
@@ -133,38 +140,39 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
return etcd_nodes, per_node_timeout, per_node_retries - 1 return etcd_nodes, per_node_timeout, per_node_retries - 1
def reload_config(self, config): def reload_config(self, config: Dict[str, Any]) -> None:
self.username = config.get('username') self.username = config.get('username')
self.password = config.get('password') self.password = config.get('password')
def _get_headers(self): def _get_headers(self) -> Dict[str, str]:
basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None
return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT) return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
def _prepare_common_parameters(self, etcd_nodes, timeout=None): def _prepare_common_parameters(self, etcd_nodes: int, timeout: Optional[float] = None) -> Dict[str, Any]:
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect, 'preload_content': False} kwargs: Dict[str, Any] = {'headers': self._get_headers(),
'redirect': self.allow_redirect, 'preload_content': False}
if timeout is not None: if timeout is not None:
kwargs.update(retries=0, timeout=timeout) kwargs.update(retries=0, timeout=timeout)
else: else:
_, per_node_timeout, per_node_retries = self._calculate_timeouts(etcd_nodes) _, per_node_timeout, per_node_retries = self._calculate_timeouts(etcd_nodes)
connect_timeout = max(1, per_node_timeout/2) connect_timeout = max(1.0, per_node_timeout / 2.0)
kwargs.update(timeout=Timeout(connect=connect_timeout, total=per_node_timeout), retries=per_node_retries) kwargs.update(timeout=Timeout(connect=connect_timeout, total=per_node_timeout), retries=per_node_retries)
return kwargs return kwargs
def set_machines_cache_ttl(self, cache_ttl): def set_machines_cache_ttl(self, cache_ttl: int) -> None:
self._machines_cache_ttl = cache_ttl self._machines_cache_ttl = cache_ttl
@abc.abstractmethod @abc.abstractmethod
def _prepare_get_members(self, etcd_nodes): def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
"""returns: request parameters""" """returns: request parameters"""
@abc.abstractmethod @abc.abstractmethod
def _get_members(self, base_uri, **kwargs): def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
"""returns: list of clientURLs""" """returns: list of clientURLs"""
@property @property
def machines_cache(self): def machines_cache(self) -> List[str]:
base_uri, cache = self._base_uri, self._machines_cache base_uri, cache = self._base_uri, self._machines_cache
return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri] return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri]
@@ -207,10 +215,14 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
return self._get_machines_list(self.machines_cache) return self._get_machines_list(self.machines_cache)
def set_read_timeout(self, timeout): def set_read_timeout(self, timeout: float) -> None:
self._read_timeout = timeout self._read_timeout = timeout
def _do_http_request(self, retry, machines_cache, request_executor, method, path, fields=None, **kwargs): def _do_http_request(self, retry: Optional[Retry], machines_cache: List[str],
request_executor: Callable[..., urllib3.response.HTTPResponse],
method: str, path: str, fields: Optional[Dict[str, Any]] = None,
**kwargs: Any) -> urllib3.response.HTTPResponse:
is_watch_request = isinstance(fields, dict) and fields.get('wait') == 'true'
if fields is not None: if fields is not None:
kwargs['fields'] = fields kwargs['fields'] = fields
some_request_failed = False some_request_failed = False
@@ -233,8 +245,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
# whether the key didn't received an update or there is a network problem. # whether the key didn't received an update or there is a network problem.
elif i + 1 < len(machines_cache): elif i + 1 < len(machines_cache):
self.set_base_uri(machines_cache[i + 1]) self.set_base_uri(machines_cache[i + 1])
if (isinstance(fields, dict) and fields.get("wait") == "true" and if is_watch_request and isinstance(e, (ReadTimeoutError, ProtocolError)):
isinstance(e, (ReadTimeoutError, ProtocolError))):
logger.debug("Watch timed out.") logger.debug("Watch timed out.")
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e) raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
logger.error("Request to server %s failed: %r", base_uri, e) logger.error("Request to server %s failed: %r", base_uri, e)
@@ -246,10 +257,12 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
raise etcd.EtcdConnectionFailed('No more machines in the cluster') raise etcd.EtcdConnectionFailed('No more machines in the cluster')
@abc.abstractmethod @abc.abstractmethod
def _prepare_request(self, kwargs, params=None, method=None): def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
"""returns: request_executor""" """returns: request_executor"""
def api_execute(self, path, method, params=None, timeout=None): def api_execute(self, path: str, method: str, params: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None) -> Any:
retry = params.pop('retry', None) if isinstance(params, dict) else None retry = params.pop('retry', None) if isinstance(params, dict) else None
# Update machines_cache if previous attempt of update has failed # Update machines_cache if previous attempt of update has failed
@@ -277,6 +290,8 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
etcd_nodes = len(machines_cache) etcd_nodes = len(machines_cache)
except Exception as e: except Exception as e:
logger.debug('Failed to update list of etcd nodes: %r', e) logger.debug('Failed to update list of etcd nodes: %r', e)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(retry, Retry) # etcd.EtcdConnectionFailed is raised only if retry is not None!
sleeptime = retry.sleeptime sleeptime = retry.sleeptime
remaining_time = retry.stoptime - sleeptime - time.time() remaining_time = retry.stoptime - sleeptime - time.time()
nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time) nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time)
@@ -287,22 +302,22 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
retry.sleep_func(sleeptime) retry.sleep_func(sleeptime)
retry.update_delay() retry.update_delay()
# We still have some time left. Partially reduce `machines_cache` and retry request # We still have some time left. Partially reduce `machines_cache` and retry request
kwargs.update(timeout=Timeout(connect=max(1, timeout/2), total=timeout), retries=retries) kwargs.update(timeout=Timeout(connect=max(1.0, timeout / 2.0), total=timeout), retries=retries)
machines_cache = machines_cache[:nodes] machines_cache = machines_cache[:nodes]
@staticmethod @staticmethod
def get_srv_record(host): def get_srv_record(host: str) -> List[Tuple[str, int]]:
try: try:
return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')] return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')]
except DNSException: except DNSException:
return [] return []
def _get_machines_cache_from_srv(self, srv, srv_suffix=None): def _get_machines_cache_from_srv(self, srv: str, srv_suffix: Optional[str] = None) -> List[str]:
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record. """Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
This record should contain list of host and peer ports which could be used to run This record should contain list of host and peer ports which could be used to run
'GET http://{host}:{port}/members' request (peer protocol)""" 'GET http://{host}:{port}/members' request (peer protocol)"""
ret = [] ret: List[str] = []
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']: for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r
protocol = 'https' if '-ssl' in r else 'http' protocol = 'https' if '-ssl' in r else 'http'
@@ -327,15 +342,15 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
logger.warning('Can not resolve SRV for %s', srv) logger.warning('Can not resolve SRV for %s', srv)
return list(set(ret)) return list(set(ret))
def _get_machines_cache_from_dns(self, host, port): def _get_machines_cache_from_dns(self, host: str, port: int) -> List[str]:
"""One host might be resolved into multiple ip addresses. We will make list out of it""" """One host might be resolved into multiple ip addresses. We will make list out of it"""
if self.protocol == 'http': if self.protocol == 'http':
ret = map(lambda res: uri(self.protocol, res[-1][:2]), self._dns_resolver.resolve(host, port)) ret = [uri(self.protocol, res[-1][:2]) for res in self._dns_resolver.resolve(host, port)]
if ret: if ret:
return list(set(ret)) return list(set(ret))
return [uri(self.protocol, (host, port))] return [uri(self.protocol, (host, port))]
def _get_machines_cache_from_config(self): def _get_machines_cache_from_config(self) -> List[str]:
if 'proxy' in self._config: if 'proxy' in self._config:
return [uri(self.protocol, (self._config['host'], self._config['port']))] return [uri(self.protocol, (self._config['host'], self._config['port']))]
@@ -351,13 +366,14 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
return machines_cache return machines_cache
@staticmethod @staticmethod
def _update_dns_cache(func, machines): def _update_dns_cache(func: Callable[[str, int], None], machines: List[str]) -> None:
for url in machines: for url in machines:
r = urlparse(url) r = urlparse(url)
port = r.port or (443 if r.scheme == 'https' else 80) if r.hostname:
func(r.hostname, port) port = r.port or (443 if r.scheme == 'https' else 80)
func(r.hostname, port)
def _load_machines_cache(self): def _load_machines_cache(self) -> bool:
"""This method should fill up `_machines_cache` from scratch. """This method should fill up `_machines_cache` from scratch.
It could happen only in two cases: It could happen only in two cases:
1. During class initialization 1. During class initialization
@@ -417,7 +433,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
self._machines_cache_updated = time.time() self._machines_cache_updated = time.time()
return ret return ret
def set_base_uri(self, value): def set_base_uri(self, value: str) -> None:
if self._base_uri != value: if self._base_uri != value:
logger.info('Selected new etcd server %s', value) logger.info('Selected new etcd server %s', value)
self._base_uri = value self._base_uri = value
@@ -427,22 +443,25 @@ class EtcdClient(AbstractEtcdClientWithFailover):
ERROR_CLS = EtcdError ERROR_CLS = EtcdError
def __del__(self): def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
if self.http is not None: super(EtcdClient, self).__init__({**config, 'version_prefix': None}, dns_resolver, cache_ttl)
try:
self.http.clear()
except (ReferenceError, TypeError, AttributeError):
pass
def _prepare_get_members(self, etcd_nodes): def __del__(self) -> None:
try:
self.http.clear()
except (ReferenceError, TypeError, AttributeError):
pass
def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
return self._prepare_common_parameters(etcd_nodes) return self._prepare_common_parameters(etcd_nodes)
def _get_members(self, base_uri, **kwargs): def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs) response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs)
data = self._handle_server_response(response).data.decode('utf-8') data = self._handle_server_response(response).data.decode('utf-8')
return [m.strip() for m in data.split(',') if m.strip()] return [m.strip() for m in data.split(',') if m.strip()]
def _prepare_request(self, kwargs, params=None, method=None): def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
kwargs['fields'] = params kwargs['fields'] = params
if method in (self._MPOST, self._MPUT): if method in (self._MPOST, self._MPUT):
kwargs['encode_multipart'] = False kwargs['encode_multipart'] = False
@@ -451,25 +470,32 @@ class EtcdClient(AbstractEtcdClientWithFailover):
class AbstractEtcd(AbstractDCS): class AbstractEtcd(AbstractDCS):
def __init__(self, config, client_cls, retry_errors_cls): def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
super(AbstractEtcd, self).__init__(config) super(AbstractEtcd, self).__init__(config)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1, self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=retry_errors_cls) retry_exceptions=retry_errors_cls)
self._ttl = int(config.get('ttl') or 30) self._ttl = int(config.get('ttl') or 30)
self._client = self.get_etcd_client(config, client_cls) self._abstract_client = self.get_etcd_client(config, client_cls)
self.__do_not_watch = False self.__do_not_watch = False
self._has_failed = False self._has_failed = False
def reload_config(self, config): @property
@abc.abstractmethod
def _client(self) -> AbstractEtcdClientWithFailover:
"""return correct type of etcd client"""
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
super(AbstractEtcd, self).reload_config(config) super(AbstractEtcd, self).reload_config(config)
self._client.reload_config(config.get(self.__class__.__name__.lower(), {})) self._client.reload_config(config.get(self.__class__.__name__.lower(), {}))
def retry(self, *args, **kwargs): def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
retry = self._retry.copy() retry = self._retry.copy()
kwargs['retry'] = retry kwargs['retry'] = retry
return retry(*args, **kwargs) return retry(method, *args, **kwargs)
def _handle_exception(self, e, name='', do_sleep=False, raise_ex=None): def _handle_exception(self, e: Exception, name: str = '', do_sleep: bool = False,
raise_ex: Optional[Exception] = None) -> None:
if not self._has_failed: if not self._has_failed:
logger.exception(name) logger.exception(name)
else: else:
@@ -480,7 +506,18 @@ class AbstractEtcd(AbstractDCS):
if isinstance(raise_ex, Exception): if isinstance(raise_ex, Exception):
raise raise_ex raise raise_ex
def _run_and_handle_exceptions(self, method, *args, **kwargs): def handle_etcd_exceptions(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
retval = func(self, *args, **kwargs)
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
def _run_and_handle_exceptions(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
retry = kwargs.pop('retry', self.retry) retry = kwargs.pop('retry', self.retry)
try: try:
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs) return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
@@ -492,19 +529,20 @@ class AbstractEtcd(AbstractDCS):
except Exception as e: except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error')) self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
@staticmethod def set_socket_options(self, sock: socket.socket,
def set_socket_options(sock, socket_options): socket_options: Optional[Collection[Tuple[int, int, int]]]) -> None:
if socket_options: if socket_options:
for opt in socket_options: for opt in socket_options:
sock.setsockopt(*opt) sock.setsockopt(*opt)
def get_etcd_client(self, config, client_cls): def get_etcd_client(self, config: Dict[str, Any],
client_cls: Type[AbstractEtcdClientWithFailover]) -> AbstractEtcdClientWithFailover:
config = deepcopy(config) config = deepcopy(config)
if 'proxy' in config: if 'proxy' in config:
config['use_proxies'] = True config['use_proxies'] = True
config['url'] = config['proxy'] config['url'] = config['proxy']
if 'url' in config: if 'url' in config and isinstance(config['url'], str):
r = urlparse(config['url']) r = urlparse(config['url'])
config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379, config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379,
'username': r.username, 'password': r.password}) 'username': r.username, 'password': r.password})
@@ -516,10 +554,11 @@ class AbstractEtcd(AbstractDCS):
if isinstance(hosts, str): if isinstance(hosts, str):
hosts = hosts.split(',') hosts = hosts.split(',')
config['hosts'] = [] config_hosts: List[str] = []
for value in hosts: for value in hosts:
if isinstance(value, str): if isinstance(value, str):
config['hosts'].append(uri(protocol, split_host_port(value.strip(), default_port))) config_hosts.append(uri(protocol, split_host_port(value.strip(), default_port)))
config['hosts'] = config_hosts
elif 'host' in config: elif 'host' in config:
host, port = split_host_port(config['host'], 2379) host, port = split_host_port(config['host'], 2379)
config['host'] = host config['host'] = host
@@ -538,8 +577,10 @@ class AbstractEtcd(AbstractDCS):
dns_resolver = DnsCachingResolver() dns_resolver = DnsCachingResolver()
def create_connection_patched(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, def create_connection_patched(
source_address=None, socket_options=None): address: Tuple[str, int], timeout: Any = object(),
source_address: Optional[Any] = None, socket_options: Optional[Collection[Tuple[int, int, int]]] = None
) -> socket.socket:
host, port = address host, port = address
if host.startswith('['): if host.startswith('['):
host = host.strip('[]') host = host.strip('[]')
@@ -549,7 +590,7 @@ class AbstractEtcd(AbstractDCS):
try: try:
sock = socket.socket(af, socktype, proto) sock = socket.socket(af, socktype, proto)
self.set_socket_options(sock, socket_options) self.set_socket_options(sock, socket_options)
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: if timeout is None or isinstance(timeout, (float, int)):
sock.settimeout(timeout) sock.settimeout(timeout)
if source_address: if source_address:
sock.bind(source_address) sock.bind(source_address)
@@ -580,51 +621,49 @@ class AbstractEtcd(AbstractDCS):
time.sleep(5) time.sleep(5)
return client return client
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
ttl = int(ttl) ttl = int(ttl)
ret = self._ttl != ttl ret = self._ttl != ttl
self._ttl = ttl self._ttl = ttl
self._client.set_machines_cache_ttl(ttl*10) self._client.set_machines_cache_ttl(ttl * 10)
return ret return ret
@property @property
def ttl(self): def ttl(self) -> int:
return self._ttl return self._ttl
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self._retry.deadline = retry_timeout self._retry.deadline = retry_timeout
self._client.set_read_timeout(retry_timeout) self._client.set_read_timeout(retry_timeout)
def catch_etcd_errors(func): def catch_etcd_errors(func: Callable[..., Any]) -> Any:
def wrapper(self, *args, **kwargs): def wrapper(self: AbstractEtcd, *args: Any, **kwargs: Any) -> Any:
try: return self.handle_etcd_exceptions(func, *args, **kwargs)
retval = func(self, *args, **kwargs) is not None
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
return wrapper return wrapper
class Etcd(AbstractEtcd): class Etcd(AbstractEtcd):
def __init__(self, config): def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal)) super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self.__do_not_watch = False self.__do_not_watch = False
def set_ttl(self, ttl): @property
def _client(self) -> EtcdClient:
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self._abstract_client, EtcdClient)
return self._abstract_client
def set_ttl(self, ttl: int) -> Optional[bool]:
self.__do_not_watch = super(Etcd, self).set_ttl(ttl) self.__do_not_watch = super(Etcd, self).set_ttl(ttl)
return None
@staticmethod @staticmethod
def member(node): def member(node: etcd.EtcdResult) -> Member:
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value) return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
def _cluster_from_nodes(self, etcd_index, nodes): def _cluster_from_nodes(self, etcd_index: int, nodes: Dict[str, etcd.EtcdResult]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize.value initialize = initialize and initialize.value
@@ -652,7 +691,7 @@ class Etcd(AbstractEtcd):
slots = None slots = None
try: try:
last_lsn = int(last_lsn) last_lsn = int(last_lsn or '')
except Exception: except Exception:
last_lsn = 0 last_lsn = 0
@@ -664,8 +703,8 @@ class Etcd(AbstractEtcd):
if leader: if leader:
member = Member(-1, leader.value, None, {}) member = Member(-1, leader.value, None, {})
member = ([m for m in members if m.name == leader.value] or [member])[0] member = ([m for m in members if m.name == leader.value] or [member])[0]
index = etcd_index if etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1 version = etcd_index if etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1
leader = Leader(index, leader.ttl, member) leader = Leader(version, leader.ttl, member)
# failover key # failover key
failover = nodes.get(self._FAILOVER) failover = nodes.get(self._FAILOVER)
@@ -685,21 +724,23 @@ class Etcd(AbstractEtcd):
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _cluster_loader(self, path): def _cluster_loader(self, path: str) -> Cluster:
result = self.retry(self._client.read, path, recursive=True) result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves} nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
return self._cluster_from_nodes(result.etcd_index, nodes) return self._cluster_from_nodes(result.etcd_index, nodes)
def _citus_cluster_loader(self, path): def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters = defaultdict(dict) clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
result = self.retry(self._client.read, path, recursive=True) result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
for node in result.leaves: for node in result.leaves:
key = node.key[len(result.key):].lstrip('/').split('/', 1) key = node.key[len(result.key):].lstrip('/').split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]): if len(key) == 2 and citus_group_re.match(key[0]):
clusters[int(key[0])][key[1]] = node clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
cluster = None cluster = None
try: try:
cluster = loader(path) cluster = loader(path)
@@ -708,18 +749,20 @@ class Etcd(AbstractEtcd):
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
if TYPE_CHECKING: # pragma: no cover
assert cluster is not None
return cluster return cluster
@catch_etcd_errors @catch_etcd_errors
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
data = json.dumps(data, separators=(',', ':')) value = json.dumps(data, separators=(',', ':'))
return self._client.set(self.member_path, data, self._ttl) return bool(self._client.set(self.member_path, value, self._ttl))
@catch_etcd_errors @catch_etcd_errors
def take_leader(self): def take_leader(self) -> bool:
return self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl) return self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl)
def _do_attempt_to_acquire_leader(self): def _do_attempt_to_acquire_leader(self) -> bool:
try: try:
return bool(self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl, prevExist=False)) return bool(self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl, prevExist=False))
except etcd.EtcdAlreadyExist: except etcd.EtcdAlreadyExist:
@@ -727,26 +770,26 @@ class Etcd(AbstractEtcd):
return False return False
@catch_return_false_exception @catch_return_false_exception
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
return self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry=None) return self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry=None)
@catch_etcd_errors @catch_etcd_errors
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.write(self.failover_path, value, prevIndex=index or 0) return bool(self._client.write(self.failover_path, value, prevIndex=version or 0))
@catch_etcd_errors @catch_etcd_errors
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.write(self.config_path, value, prevIndex=index or 0) return bool(self._client.write(self.config_path, value, prevIndex=version or 0))
@catch_etcd_errors @catch_etcd_errors
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._client.set(self.leader_optime_path, last_lsn) return bool(self._client.set(self.leader_optime_path, last_lsn))
@catch_etcd_errors @catch_etcd_errors
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._client.set(self.status_path, value) return bool(self._client.set(self.status_path, value))
def _do_update_leader(self): def _do_update_leader(self) -> bool:
try: try:
return self.retry(self._client.write, self.leader_path, self._name, return self.retry(self._client.write, self.leader_path, self._name,
prevValue=self._name, ttl=self._ttl) is not None prevValue=self._name, ttl=self._ttl) is not None
@@ -754,52 +797,52 @@ class Etcd(AbstractEtcd):
return self._do_attempt_to_acquire_leader() return self._do_attempt_to_acquire_leader()
@catch_etcd_errors @catch_etcd_errors
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._client.set(self.failsafe_path, value) return bool(self._client.set(self.failsafe_path, value))
@catch_return_false_exception @catch_return_false_exception
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
return self._run_and_handle_exceptions(self._do_update_leader, retry=None) return bool(self._run_and_handle_exceptions(self._do_update_leader, retry=None))
@catch_etcd_errors @catch_etcd_errors
def initialize(self, create_new=True, sysid=""): def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
return self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new)) return bool(self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new)))
@catch_etcd_errors @catch_etcd_errors
def _delete_leader(self): def _delete_leader(self) -> bool:
return self._client.delete(self.leader_path, prevValue=self._name) return bool(self._client.delete(self.leader_path, prevValue=self._name))
@catch_etcd_errors @catch_etcd_errors
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self.retry(self._client.delete, self.initialize_path) return bool(self.retry(self._client.delete, self.initialize_path))
@catch_etcd_errors @catch_etcd_errors
def delete_cluster(self): def delete_cluster(self) -> bool:
return self.retry(self._client.delete, self.client_path(''), recursive=True) return bool(self.retry(self._client.delete, self.client_path(''), recursive=True))
@catch_etcd_errors @catch_etcd_errors
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._client.write(self.history_path, value) return bool(self._client.write(self.history_path, value))
@catch_etcd_errors @catch_etcd_errors
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self.retry(self._client.write, self.sync_path, value, prevIndex=index or 0) return self.retry(self._client.write, self.sync_path, value, prevIndex=version or 0).modifiedIndex
@catch_etcd_errors @catch_etcd_errors
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self.retry(self._client.delete, self.sync_path, prevIndex=index or 0) return bool(self.retry(self._client.delete, self.sync_path, prevIndex=version or 0))
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
if self.__do_not_watch: if self.__do_not_watch:
self.__do_not_watch = False self.__do_not_watch = False
return True return True
if leader_index: if leader_version:
end_time = time.time() + timeout end_time = time.time() + timeout
while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try: try:
result = self._client.watch(self.leader_path, index=leader_index, timeout=timeout + 0.5) result = self._client.watch(self.leader_path, index=leader_version, timeout=timeout + 0.5)
self._has_failed = False self._has_failed = False
if result.action == 'compareAndSwap': if result.action == 'compareAndSwap':
time.sleep(0.01) time.sleep(0.01)
+292 -214
View File
@@ -10,12 +10,14 @@ import time
import urllib3 import urllib3
from collections import defaultdict from collections import defaultdict
from threading import Condition, Lock, Thread from enum import IntEnum
from urllib3.exceptions import ReadTimeoutError, ProtocolError from urllib3.exceptions import ReadTimeoutError, ProtocolError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState,\ from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re TimelineHistory, catch_return_false_exception, citus_group_re
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
from ..exceptions import DCSError, PatroniException from ..exceptions import DCSError, PatroniException
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
@@ -31,11 +33,27 @@ class UnsupportedEtcdVersion(PatroniException):
# google.golang.org/grpc/codes # google.golang.org/grpc/codes
GRPCCode = type('Enum', (), {'OK': 0, 'Canceled': 1, 'Unknown': 2, 'InvalidArgument': 3, 'DeadlineExceeded': 4, class GRPCCode(IntEnum):
'NotFound': 5, 'AlreadyExists': 6, 'PermissionDenied': 7, 'ResourceExhausted': 8, OK = 0
'FailedPrecondition': 9, 'Aborted': 10, 'OutOfRange': 11, 'Unimplemented': 12, Canceled = 1
'Internal': 13, 'Unavailable': 14, 'DataLoss': 15, 'Unauthenticated': 16}) Unknown = 2
GRPCcodeToText = {v: k for k, v in GRPCCode.__dict__.items() if not k.startswith('__') and isinstance(v, int)} InvalidArgument = 3
DeadlineExceeded = 4
NotFound = 5
AlreadyExists = 6
PermissionDenied = 7
ResourceExhausted = 8
FailedPrecondition = 9
Aborted = 10
OutOfRange = 11
Unimplemented = 12
Internal = 13
Unavailable = 14
DataLoss = 15
Unauthenticated = 16
GRPCcodeToText: Dict[int, str] = {v: k for k, v in GRPCCode.__dict__['_member_map_'].items()}
class Etcd3Exception(etcd.EtcdException): class Etcd3Exception(etcd.EtcdException):
@@ -44,22 +62,24 @@ class Etcd3Exception(etcd.EtcdException):
class Etcd3ClientError(Etcd3Exception): class Etcd3ClientError(Etcd3Exception):
def __init__(self, code=None, error=None, status=None): def __init__(self, code: Optional[int] = None, error: Optional[str] = None, status: Optional[int] = None) -> None:
if not hasattr(self, 'error'): if not hasattr(self, 'error'):
self.error = error and error.strip() self.error = error and error.strip()
self.codeText = GRPCcodeToText.get(code) self.codeText = GRPCcodeToText.get(code) if code is not None else None
self.status = status self.status = status
def __repr__(self): def __repr__(self) -> str:
return "<{0} error: '{1}', code: {2}>".format(self.__class__.__name__, self.error, self.code) return "<{0} error: '{1}', code: {2}>"\
.format(self.__class__.__name__, getattr(self, 'error', None), getattr(self, 'code', None))
__str__ = __repr__ __str__ = __repr__
def as_dict(self): def as_dict(self) -> Dict[str, Any]:
return {'error': self.error, 'code': self.code, 'codeText': self.codeText, 'status': self.status} return {'error': getattr(self, 'error', None), 'code': getattr(self, 'code', None),
'codeText': self.codeText, 'status': self.status}
@classmethod @classmethod
def get_subclasses(cls): def get_subclasses(cls) -> Iterator[Type['Etcd3ClientError']]:
for subclass in cls.__subclasses__(): for subclass in cls.__subclasses__():
for subsubclass in subclass.get_subclasses(): for subsubclass in subclass.get_subclasses():
yield subsubclass yield subsubclass
@@ -118,63 +138,75 @@ class InvalidAuthToken(Etcd3ClientError):
error = "etcdserver: invalid auth token" error = "etcdserver: invalid auth token"
errStringToClientError = {s.error: s for s in Etcd3ClientError.get_subclasses() if hasattr(s, 'error')} errStringToClientError = {getattr(s, 'error'): s for s in Etcd3ClientError.get_subclasses() if hasattr(s, 'error')}
errCodeToClientError = {s.code: s for s in Etcd3ClientError.__subclasses__()} errCodeToClientError = {getattr(s, 'code'): s for s in Etcd3ClientError.__subclasses__()}
def _raise_for_data(data, status_code=None): def _raise_for_data(data: Union[bytes, str, Dict[str, Union[Any, Dict[str, Any]]]],
status_code: Optional[int] = None) -> Etcd3ClientError:
try: try:
error = data.get('error') or data.get('Error') if TYPE_CHECKING: # pragma: no cover
if isinstance(error, dict): # streaming response assert isinstance(data, dict)
status_code = error.get('http_code') data_error: Optional[Dict[str, Any]] = data.get('error') or data.get('Error')
code = error['grpc_code'] if isinstance(data_error, dict): # streaming response
error = error['message'] status_code = data_error.get('http_code')
code: Optional[int] = data_error['grpc_code']
error: str = data_error['message']
else: else:
code = data.get('code') or data.get('Code') data_code = data.get('code') or data.get('Code')
if TYPE_CHECKING: # pragma: no cover
assert not isinstance(data_code, dict)
code = data_code
error = str(data_error)
except Exception: except Exception:
error = str(data) error = str(data)
code = GRPCCode.Unknown code = GRPCCode.Unknown
err = errStringToClientError.get(error) or errCodeToClientError.get(code) or Unknown err = errStringToClientError.get(error) or errCodeToClientError.get(code) or Unknown
raise err(code, error, status_code) return err(code, error, status_code)
def to_bytes(v): def to_bytes(v: Union[str, bytes]) -> bytes:
return v if isinstance(v, bytes) else v.encode('utf-8') return v if isinstance(v, bytes) else v.encode('utf-8')
def prefix_range_end(v): def prefix_range_end(v: str) -> bytes:
v = bytearray(to_bytes(v)) ret = bytearray(to_bytes(v))
for i in range(len(v) - 1, -1, -1): for i in range(len(ret) - 1, -1, -1):
if v[i] < 0xff: if ret[i] < 0xff:
v[i] += 1 ret[i] += 1
break break
return bytes(v) return bytes(ret)
def base64_encode(v): def base64_encode(v: Union[str, bytes]) -> str:
return base64.b64encode(to_bytes(v)).decode('utf-8') return base64.b64encode(to_bytes(v)).decode('utf-8')
def base64_decode(v): def base64_decode(v: str) -> str:
return base64.b64decode(v).decode('utf-8') return base64.b64decode(v).decode('utf-8')
def build_range_request(key, range_end=None): def build_range_request(key: str, range_end: Union[bytes, str, None] = None) -> Dict[str, Any]:
fields = {'key': base64_encode(key)} fields = {'key': base64_encode(key)}
if range_end: if range_end:
fields['range_end'] = base64_encode(range_end) fields['range_end'] = base64_encode(range_end)
return fields return fields
def _handle_auth_errors(func: Callable[..., Any]) -> Any:
def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any:
return self.handle_auth_errors(func, *args, **kwargs)
return wrapper
class Etcd3Client(AbstractEtcdClientWithFailover): class Etcd3Client(AbstractEtcdClientWithFailover):
ERROR_CLS = Etcd3Error ERROR_CLS = Etcd3Error
def __init__(self, config, dns_resolver, cache_ttl=300): def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
self._token = None self._token = None
self._cluster_version = None self._cluster_version: Tuple[int] = tuple()
self.version_prefix = '/v3beta' super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
super(Etcd3Client, self).__init__(config, dns_resolver, cache_ttl)
try: try:
self.authenticate() self.authenticate()
@@ -182,32 +214,33 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
logger.fatal('Etcd3 authentication failed: %r', e) logger.fatal('Etcd3 authentication failed: %r', e)
sys.exit(1) sys.exit(1)
def _get_headers(self): def _get_headers(self) -> Dict[str, str]:
headers = urllib3.make_headers(user_agent=USER_AGENT) headers = urllib3.make_headers(user_agent=USER_AGENT)
if self._token and self._cluster_version >= (3, 3, 0): if self._token and self._cluster_version >= (3, 3, 0):
headers['authorization'] = self._token headers['authorization'] = self._token
return headers return headers
def _prepare_request(self, kwargs, params=None, method=None): def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
if params is not None: if params is not None:
kwargs['body'] = json.dumps(params) kwargs['body'] = json.dumps(params)
kwargs['headers']['Content-Type'] = 'application/json' kwargs['headers']['Content-Type'] = 'application/json'
return self.http.urlopen return self.http.urlopen
@staticmethod def _handle_server_response(self, response: urllib3.response.HTTPResponse) -> Dict[str, Any]:
def _handle_server_response(response):
data = response.data data = response.data
try: try:
data = data.decode('utf-8') data = data.decode('utf-8')
data = json.loads(data) ret: Dict[str, Any] = json.loads(data)
if response.status < 400:
return ret
except (TypeError, ValueError, UnicodeError) as e: except (TypeError, ValueError, UnicodeError) as e:
if response.status < 400: if response.status < 400:
raise etcd.EtcdException('Server response was not valid JSON: %r' % e) raise etcd.EtcdException('Server response was not valid JSON: %r' % e)
if response.status < 400: ret = {}
return data raise _raise_for_data(ret or data, response.status)
_raise_for_data(data, response.status)
def _ensure_version_prefix(self, base_uri, **kwargs): def _ensure_version_prefix(self, base_uri: str, **kwargs: Any) -> None:
if self.version_prefix != '/v3': if self.version_prefix != '/v3':
response = self.http.urlopen(self._MGET, base_uri + '/version', **kwargs) response = self.http.urlopen(self._MGET, base_uri + '/version', **kwargs)
response = self._handle_server_response(response) response = self._handle_server_response(response)
@@ -234,87 +267,92 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
else: else:
self.version_prefix = '/v3' self.version_prefix = '/v3'
def _prepare_get_members(self, etcd_nodes): def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
kwargs = self._prepare_common_parameters(etcd_nodes) kwargs = self._prepare_common_parameters(etcd_nodes)
self._prepare_request(kwargs, {}) self._prepare_request(kwargs, {})
return kwargs return kwargs
def _get_members(self, base_uri, **kwargs): def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
self._ensure_version_prefix(base_uri, **kwargs) self._ensure_version_prefix(base_uri, **kwargs)
resp = self.http.urlopen(self._MPOST, base_uri + self.version_prefix + '/cluster/member/list', **kwargs) resp = self.http.urlopen(self._MPOST, base_uri + self.version_prefix + '/cluster/member/list', **kwargs)
members = self._handle_server_response(resp)['members'] members = self._handle_server_response(resp)['members']
return set(url for member in members for url in member.get('clientURLs', [])) return [url for member in members for url in member.get('clientURLs', [])]
def call_rpc(self, method, fields, retry=None): def call_rpc(self, method: str, fields: Dict[str, Any], retry: Optional[Retry] = None) -> Dict[str, Any]:
fields['retry'] = retry fields['retry'] = retry
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) -> bool:
if self._use_proxies and self._cluster_version is None: if self._use_proxies and not self._cluster_version:
kwargs = self._prepare_common_parameters(1) kwargs = self._prepare_common_parameters(1)
self._ensure_version_prefix(self._base_uri, **kwargs) self._ensure_version_prefix(self._base_uri, **kwargs)
if self._cluster_version >= (3, 3) and self.username and self.password: if not (self._cluster_version >= (3, 3) and self.username and self.password):
logger.info('Trying to authenticate on Etcd...') return False
old_token, self._token = self._token, None logger.info('Trying to authenticate on Etcd...')
try: old_token, self._token = self._token, None
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password}) try:
except AuthNotEnabled: response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password})
logger.info('Etcd authentication is not enabled') except AuthNotEnabled:
self._token = None logger.info('Etcd authentication is not enabled')
except Exception: self._token = None
self._token = old_token except Exception:
raise self._token = old_token
else: raise
self._token = response.get('token') else:
return old_token != self._token self._token = response.get('token')
return old_token != self._token
def _handle_auth_errors(func): def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
def wrapper(self, *args, **kwargs): def retry(ex: Exception) -> Any:
def retry(ex): if self.username and self.password:
if self.username and self.password: self.authenticate()
self.authenticate()
return func(self, *args, **kwargs)
else:
logger.fatal('Username or password not set, authentication is not possible')
raise ex
try:
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided else:
# PermissionDenied is raised on 3.0 and 3.1 logger.fatal('Username or password not set, authentication is not possible')
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied) raise ex
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
return retry(e)
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
return retry(e)
return wrapper try:
return func(self, *args, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided
# PermissionDenied is raised on 3.0 and 3.1
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
return retry(e)
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
return retry(e)
@_handle_auth_errors @_handle_auth_errors
def range(self, key, range_end=None, retry=None): def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
retry: Optional[Retry] = None) -> Dict[str, Any]:
params = build_range_request(key, range_end) params = build_range_request(key, range_end)
params['serializable'] = True # For better performance. We can tolerate stale reads. params['serializable'] = serializable # For better performance. We can tolerate stale reads
return self.call_rpc('/kv/range', params, retry) return self.call_rpc('/kv/range', params, retry)
def prefix(self, key, retry=None): def prefix(self, key: str, serializable: bool = True, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.range(key, prefix_range_end(key), retry) return self.range(key, prefix_range_end(key), serializable, retry)
@_handle_auth_errors @_handle_auth_errors
def lease_grant(self, ttl, retry=None): def lease_grant(self, ttl: int, retry: Optional[Retry] = None) -> str:
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID'] return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
def lease_keepalive(self, ID, retry=None): def lease_keepalive(self, ID: str, retry: Optional[Retry] = None) -> Optional[str]:
return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL') return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL')
def txn(self, compare, success, retry=None): def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
return self.call_rpc('/kv/txn', {'compare': [compare], 'success': [success]}, retry).get('succeeded') failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = {'compare': [compare], 'success': [success]}
if failure:
fields['failure'] = [failure]
ret = self.call_rpc('/kv/txn', fields, retry)
return ret if failure or ret.get('succeeded') else {}
@_handle_auth_errors @_handle_auth_errors
def put(self, key, value, lease=None, create_revision=None, mod_revision=None, retry=None): def put(self, key: str, value: str, lease: Optional[str] = None, create_revision: Optional[str] = None,
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = {'key': base64_encode(key), 'value': base64_encode(value)} fields = {'key': base64_encode(key), 'value': base64_encode(value)}
if lease: if lease:
fields['lease'] = lease fields['lease'] = lease
@@ -325,20 +363,23 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
else: else:
return self.call_rpc('/kv/put', fields, retry) return self.call_rpc('/kv/put', fields, retry)
compare['key'] = fields['key'] compare['key'] = fields['key']
return self.txn(compare, {'request_put': fields}, retry) return self.txn(compare, {'request_put': fields}, retry=retry)
@_handle_auth_errors @_handle_auth_errors
def deleterange(self, key, range_end=None, mod_revision=None, retry=None): def deleterange(self, key: str, range_end: Union[bytes, str, None] = None,
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = build_range_request(key, range_end) fields = build_range_request(key, range_end)
if mod_revision is None: if mod_revision is None:
return self.call_rpc('/kv/deleterange', fields, retry) return self.call_rpc('/kv/deleterange', fields, retry)
compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']} compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']}
return self.txn(compare, {'request_delete_range': fields}, retry) return self.txn(compare, {'request_delete_range': fields}, retry=retry)
def deleteprefix(self, key, retry=None): def deleteprefix(self, key: str, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.deleterange(key, prefix_range_end(key), retry=retry) return self.deleterange(key, prefix_range_end(key), retry=retry)
def watchrange(self, key, range_end=None, start_revision=None, filters=None, read_timeout=None): def watchrange(self, key: str, range_end: Union[bytes, str, None] = None,
start_revision: Optional[str] = None, filters: Optional[List[Dict[str, Any]]] = None,
read_timeout: Optional[float] = None) -> urllib3.response.HTTPResponse:
"""returns: response object""" """returns: response object"""
params = build_range_request(key, range_end) params = build_range_request(key, range_end)
if start_revision is not None: if start_revision is not None:
@@ -349,14 +390,16 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
kwargs.update(timeout=urllib3.Timeout(connect=kwargs['timeout'], read=read_timeout), retries=0) kwargs.update(timeout=urllib3.Timeout(connect=kwargs['timeout'], read=read_timeout), retries=0)
return request_executor(self._MPOST, self._base_uri + self.version_prefix + '/watch', **kwargs) return request_executor(self._MPOST, self._base_uri + self.version_prefix + '/watch', **kwargs)
def watchprefix(self, key, start_revision=None, filters=None, read_timeout=None): def watchprefix(self, key: str, start_revision: Optional[str] = None,
filters: Optional[List[Dict[str, Any]]] = None,
read_timeout: Optional[float] = None) -> urllib3.response.HTTPResponse:
return self.watchrange(key, prefix_range_end(key), start_revision, filters, read_timeout) return self.watchrange(key, prefix_range_end(key), start_revision, filters, read_timeout)
class KVCache(Thread): class KVCache(Thread):
def __init__(self, dcs, client): def __init__(self, dcs: 'Etcd3', client: 'PatroniEtcd3Client') -> None:
Thread.__init__(self) super(KVCache, self).__init__()
self.daemon = True self.daemon = True
self._dcs = dcs self._dcs = dcs
self._client = client self._client = client
@@ -365,7 +408,7 @@ class KVCache(Thread):
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._status_key = base64_encode(dcs.status_path)
self._name = base64_encode(dcs._name) self._name = base64_encode(getattr(dcs, '_name')) # pyright
self._is_ready = False self._is_ready = False
self._response = None self._response = None
self._response_lock = Lock() self._response_lock = Lock()
@@ -373,32 +416,32 @@ class KVCache(Thread):
self._object_cache_lock = Lock() self._object_cache_lock = Lock()
self.start() self.start()
def set(self, value, overwrite=False): def set(self, value: Dict[str, Any], overwrite: bool = False) -> Tuple[bool, Optional[Dict[str, Any]]]:
with self._object_cache_lock: with self._object_cache_lock:
name = value['key'] name = value['key']
old_value = self._object_cache.get(name) old_value = self._object_cache.get(name)
ret = not old_value or int(old_value['mod_revision']) < int(value['mod_revision']) ret = not old_value or int(old_value['mod_revision']) < int(value['mod_revision'])
if ret or overwrite and old_value['mod_revision'] == value['mod_revision']: if ret or overwrite and old_value and old_value['mod_revision'] == value['mod_revision']:
self._object_cache[name] = value self._object_cache[name] = value
return ret, old_value return ret, old_value
def delete(self, name, mod_revision): def delete(self, name: str, mod_revision: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
with self._object_cache_lock: with self._object_cache_lock:
old_value = self._object_cache.get(name) old_value = self._object_cache.get(name)
ret = old_value and int(old_value['mod_revision']) < int(mod_revision) ret = old_value and int(old_value['mod_revision']) < int(mod_revision)
if ret: if ret:
del self._object_cache[name] del self._object_cache[name]
return not old_value or ret, old_value return bool(not old_value or ret), old_value
def copy(self): def copy(self) -> List[Dict[str, Any]]:
with self._object_cache_lock: with self._object_cache_lock:
return [v.copy() for v in self._object_cache.values()] return [v.copy() for v in self._object_cache.values()]
def get(self, name): def get(self, name: str) -> Optional[Dict[str, Any]]:
with self._object_cache_lock: with self._object_cache_lock:
return self._object_cache.get(name) return self._object_cache.get(name)
def _process_event(self, event): def _process_event(self, event: Dict[str, Any]) -> None:
kv = event['kv'] kv = event['kv']
key = kv['key'] key = kv['key']
if event.get('type') == 'DELETE': if event.get('type') == 'DELETE':
@@ -411,32 +454,33 @@ 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 in (self._optime_key, self._status_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
key == self._config_key and old_value is not None and new_value is not None) or 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 (or status key) 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 not in (self._optime_key, self._status_key) or if value_changed and (key not in (self._optime_key, self._status_key)
(self.get(self._leader_key) or {}).get('value') != self._name): 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: Dict[str, Any]) -> None:
logger.debug('Received message: %s', message) logger.debug('Received message: %s', message)
if 'error' in message: if 'error' in message:
_raise_for_data(message) raise _raise_for_data(message)
for event in message.get('result', {}).get('events', []): events: List[Dict[str, Any]] = message.get('result', {}).get('events', [])
for event in events:
self._process_event(event) self._process_event(event)
@staticmethod @staticmethod
def _finish_response(response): def _finish_response(response: urllib3.response.HTTPResponse) -> None:
try: try:
response.close() response.close()
finally: finally:
response.release_conn() response.release_conn()
def _do_watch(self, revision): def _do_watch(self, revision: str) -> None:
with self._response_lock: with self._response_lock:
self._response = None self._response = None
# We do most of requests with timeouts. The only exception /watch requests to Etcd v3. # We do most of requests with timeouts. The only exception /watch requests to Etcd v3.
@@ -457,7 +501,7 @@ class KVCache(Thread):
for message in iter_response_objects(response): for message in iter_response_objects(response):
self._process_message(message) self._process_message(message)
def _build_cache(self): def _build_cache(self) -> None:
result = self._dcs.retry(self._client.prefix, self._dcs.cluster_prefix) result = self._dcs.retry(self._client.prefix, self._dcs.cluster_prefix)
with self._object_cache_lock: with self._object_cache_lock:
self._object_cache = {node['key']: node for node in result.get('kvs', [])} self._object_cache = {node['key']: node for node in result.get('kvs', [])}
@@ -476,10 +520,10 @@ class KVCache(Thread):
self._is_ready = False self._is_ready = False
with self._response_lock: with self._response_lock:
response, self._response = self._response, None response, self._response = self._response, None
if response: if isinstance(response, urllib3.response.HTTPResponse):
self._finish_response(response) self._finish_response(response)
def run(self): def run(self) -> None:
while True: while True:
try: try:
self._build_cache() self._build_cache()
@@ -487,12 +531,12 @@ class KVCache(Thread):
logger.error('KVCache.run %r', e) logger.error('KVCache.run %r', e)
time.sleep(1) time.sleep(1)
def kill_stream(self): def kill_stream(self) -> None:
sock = None sock = None
with self._response_lock: with self._response_lock:
if self._response: if isinstance(self._response, urllib3.response.HTTPResponse):
try: try:
sock = self._response.connection.sock sock = self._response.connection.sock if self._response.connection else None
except Exception: except Exception:
sock = None sock = None
else: else:
@@ -504,65 +548,72 @@ class KVCache(Thread):
except Exception as e: except Exception as e:
logger.debug('Error on socket.shutdown: %r', e) logger.debug('Error on socket.shutdown: %r', e)
def is_ready(self): def is_ready(self) -> bool:
"""Must be called only when holding the lock on `condition`""" """Must be called only when holding the lock on `condition`"""
return self._is_ready return self._is_ready
class PatroniEtcd3Client(Etcd3Client): class PatroniEtcd3Client(Etcd3Client):
def __init__(self, *args, **kwargs): def __init__(self, *args: Any, **kwargs: Any) -> None:
self._kv_cache = None self._kv_cache = None
super(PatroniEtcd3Client, self).__init__(*args, **kwargs) super(PatroniEtcd3Client, self).__init__(*args, **kwargs)
def configure(self, etcd3): def configure(self, etcd3: 'Etcd3') -> None:
self._etcd3 = etcd3 self._etcd3 = etcd3
def start_watcher(self): def start_watcher(self) -> None:
if self._cluster_version >= (3, 1): if self._cluster_version >= (3, 1):
self._kv_cache = KVCache(self._etcd3, self) self._kv_cache = KVCache(self._etcd3, self)
def _restart_watcher(self): def _restart_watcher(self) -> None:
if self._kv_cache: if self._kv_cache:
self._kv_cache.kill_stream() self._kv_cache.kill_stream()
def set_base_uri(self, value): def set_base_uri(self, value: str) -> None:
super(PatroniEtcd3Client, self).set_base_uri(value) super(PatroniEtcd3Client, self).set_base_uri(value)
self._restart_watcher() self._restart_watcher()
def authenticate(self): def authenticate(self) -> bool:
ret = super(PatroniEtcd3Client, self).authenticate() ret = super(PatroniEtcd3Client, self).authenticate()
if ret: if ret:
self._restart_watcher() self._restart_watcher()
return ret return ret
def _wait_cache(self, timeout): def _wait_cache(self, timeout: float) -> None:
stop_time = time.time() + timeout stop_time = time.time() + timeout
while not self._kv_cache.is_ready(): while self._kv_cache and not self._kv_cache.is_ready():
timeout = stop_time - time.time() timeout = stop_time - time.time()
if timeout <= 0: if timeout <= 0:
raise RetryFailedError('Exceeded retry deadline') raise RetryFailedError('Exceeded retry deadline')
self._kv_cache.condition.wait(timeout) self._kv_cache.condition.wait(timeout)
def get_cluster(self, path): def get_cluster(self, path: str) -> List[Dict[str, Any]]:
if self._kv_cache and path.startswith(self._etcd3.cluster_prefix): if self._kv_cache and path.startswith(self._etcd3.cluster_prefix):
with self._kv_cache.condition: with self._kv_cache.condition:
self._wait_cache(self._etcd3._retry.deadline) self._wait_cache(self.read_timeout)
ret = self._kv_cache.copy() ret = self._kv_cache.copy()
else: else:
ret = self._etcd3.retry(self.prefix, path).get('kvs', []) serializable = not getattr(self._etcd3, '_ctl') # use linearizable for patronictl
ret = self._etcd3.retry(self.prefix, path, serializable).get('kvs', [])
for node in ret: for node in ret:
node.update({'key': base64_decode(node['key']), node.update({'key': base64_decode(node['key']),
'value': base64_decode(node.get('value', '')), 'value': base64_decode(node.get('value', '')),
'lease': node.get('lease')}) 'lease': node.get('lease')})
return ret return ret
def call_rpc(self, method, fields, retry=None): def call_rpc(self, method: str, fields: Dict[str, Any], retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).call_rpc(method, fields, retry) ret = super(PatroniEtcd3Client, self).call_rpc(method, fields, retry)
if self._kv_cache: if self._kv_cache:
value = delete = None value = delete = None
if method == '/kv/txn' and ret.get('succeeded'): # For the 'failure' case we only support a second (nested) transaction that attempts to
# update/delete the same keys. Anything more complex than that we don't need and therefore it doesn't
# make sense to write a universal response analyzer and we can just check expected JSON path.
if method == '/kv/txn'\
and (ret.get('succeeded') or 'failure' in fields and 'request_txn' in fields['failure'][0]
and ret.get('responses', [{'response_txn': {'succeeded': False}}])[0]
.get('response_txn', {}).get('succeeded')):
on_success = fields['success'][0] on_success = fields['success'][0]
value = on_success.get('request_put') value = on_success.get('request_put')
delete = on_success.get('request_delete_range') delete = on_success.get('request_delete_range')
@@ -579,10 +630,20 @@ class PatroniEtcd3Client(Etcd3Client):
return ret return ret
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry)
# Here we abuse the fact that the `failure` is only set in the call from update_leader().
# In all other cases the txn() call failure may be an indicator of a stale cache,
# and therefore we want to restart watcher.
if not failure and not ret:
self._restart_watcher()
return ret
class Etcd3(AbstractEtcd): class Etcd3(AbstractEtcd):
def __init__(self, config): def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition)) super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
self.__do_not_watch = False self.__do_not_watch = False
self._lease = None self._lease = None
@@ -593,15 +654,25 @@ class Etcd3(AbstractEtcd):
self._client.start_watcher() self._client.start_watcher()
self.create_lease() self.create_lease()
def set_socket_options(self, sock, socket_options): @property
def _client(self) -> PatroniEtcd3Client:
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self._abstract_client, PatroniEtcd3Client)
return self._abstract_client
def set_socket_options(self, sock: socket.socket,
socket_options: Optional[Collection[Tuple[int, int, int]]]) -> None:
if TYPE_CHECKING: # pragma: no cover
assert self._retry.deadline is not None
enable_keepalive(sock, self.ttl, int(self.loop_wait + self._retry.deadline)) enable_keepalive(sock, self.ttl, int(self.loop_wait + self._retry.deadline))
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
self.__do_not_watch = super(Etcd3, self).set_ttl(ttl) self.__do_not_watch = super(Etcd3, self).set_ttl(ttl)
if self.__do_not_watch: if self.__do_not_watch:
self._lease = None self._lease = None
return None
def _do_refresh_lease(self, force=False, retry=None): def _do_refresh_lease(self, force: bool = False, retry: Optional[Retry] = None) -> bool:
if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time(): if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time():
return False return False
@@ -615,14 +686,14 @@ class Etcd3(AbstractEtcd):
self._last_lease_refresh = time.time() self._last_lease_refresh = time.time()
return ret return ret
def refresh_lease(self): def refresh_lease(self) -> bool:
try: try:
return self.retry(self._do_refresh_lease) return self.retry(self._do_refresh_lease)
except (Etcd3ClientError, RetryFailedError): except (Etcd3ClientError, RetryFailedError):
logger.exception('refresh_lease') logger.exception('refresh_lease')
raise Etcd3Error('Failed to keepalive/grant lease') raise Etcd3Error('Failed to keepalive/grant lease')
def create_lease(self): def create_lease(self) -> None:
while not self._lease: while not self._lease:
try: try:
self.refresh_lease() self.refresh_lease()
@@ -631,14 +702,14 @@ class Etcd3(AbstractEtcd):
time.sleep(5) time.sleep(5)
@property @property
def cluster_prefix(self): def cluster_prefix(self) -> str:
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('') return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
@staticmethod @staticmethod
def member(node): def member(node: Dict[str, str]) -> Member:
return Member.from_node(node['mod_revision'], os.path.basename(node['key']), node['lease'], node['value']) return Member.from_node(node['mod_revision'], os.path.basename(node['key']), node['lease'], node['value'])
def _cluster_from_nodes(self, nodes): def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['value'] initialize = initialize and initialize['value']
@@ -666,7 +737,7 @@ class Etcd3(AbstractEtcd):
slots = None slots = None
try: try:
last_lsn = int(last_lsn) last_lsn = int(last_lsn or '')
except Exception: except Exception:
last_lsn = 0 last_lsn = 0
@@ -701,14 +772,14 @@ class Etcd3(AbstractEtcd):
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _cluster_loader(self, path): def _cluster_loader(self, path: str) -> Cluster:
nodes = {node['key'][len(path):]: node nodes = {node['key'][len(path):]: node
for node in self._client.get_cluster(path) for node in self._client.get_cluster(path)
if node['key'].startswith(path)} if node['key'].startswith(path)}
return self._cluster_from_nodes(nodes) return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path): def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters = defaultdict(dict) clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
path = self._base_path + '/' path = self._base_path + '/'
for node in self._client.get_cluster(path): for node in self._client.get_cluster(path):
key = node['key'][len(path):].split('/', 1) key = node['key'][len(path):].split('/', 1)
@@ -716,7 +787,9 @@ class Etcd3(AbstractEtcd):
clusters[int(key[0])][key[1]] = node clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
cluster = None cluster = None
try: try:
cluster = loader(path) cluster = loader(path)
@@ -725,10 +798,12 @@ class Etcd3(AbstractEtcd):
except Exception as e: except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=Etcd3Error('Etcd is not responding properly')) self._handle_exception(e, 'get_cluster', raise_ex=Etcd3Error('Etcd is not responding properly'))
self._has_failed = False self._has_failed = False
if TYPE_CHECKING: # pragma: no cover
assert cluster is not None
return cluster return cluster
@catch_etcd_errors @catch_etcd_errors
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
try: try:
self.refresh_lease() self.refresh_lease()
except Etcd3Error: except Etcd3Error:
@@ -740,50 +815,47 @@ class Etcd3(AbstractEtcd):
if member and member.session == self._lease and deep_compare(data, member.data): if member and member.session == self._lease and deep_compare(data, member.data):
return True return True
data = json.dumps(data, separators=(',', ':')) value = json.dumps(data, separators=(',', ':'))
try: try:
return self._client.put(self.member_path, data, self._lease) return bool(self._client.put(self.member_path, value, self._lease))
except LeaseNotFound: except LeaseNotFound:
self._lease = None self._lease = None
logger.error('Our lease disappeared from Etcd, can not "touch_member"') logger.error('Our lease disappeared from Etcd, can not "touch_member"')
return False
@catch_etcd_errors @catch_etcd_errors
def take_leader(self): def take_leader(self) -> bool:
return self.retry(self._client.put, self.leader_path, self._name, self._lease) return self.retry(self._client.put, self.leader_path, self._name, self._lease)
def _do_attempt_to_acquire_leader(self, retry): def _do_attempt_to_acquire_leader(self, retry: Retry) -> bool:
def _retry(*args, **kwargs): def _retry(*args: Any, **kwargs: Any) -> Any:
kwargs['retry'] = retry kwargs['retry'] = retry
return retry(*args, **kwargs) return retry(*args, **kwargs)
try: try:
return _retry(self._client.put, self.leader_path, self._name, self._lease, 0) return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
except LeaseNotFound: except LeaseNotFound:
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt') logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
self._lease = None self._lease = None
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(0)
_retry(self._do_refresh_lease) _retry(self._do_refresh_lease)
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(1, Etcd3Error('_do_attempt_to_acquire_leader timeout'))
if retry.deadline < 1:
raise Etcd3Error('_do_attempt_to_acquire_leader timeout')
return _retry(self._client.put, self.leader_path, self._name, self._lease, 0) return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
@catch_return_false_exception @catch_return_false_exception
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
retry = self._retry.copy() retry = self._retry.copy()
def _retry(*args, **kwargs): def _retry(*args: Any, **kwargs: Any) -> Any:
kwargs['retry'] = retry kwargs['retry'] = retry
return retry(*args, **kwargs) return retry(*args, **kwargs)
self._run_and_handle_exceptions(self._do_refresh_lease, retry=_retry) self._run_and_handle_exceptions(self._do_refresh_lease, retry=_retry)
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(1, Etcd3Error('attempt_to_acquire_leader timeout'))
if retry.deadline < 1:
raise Etcd3Error('attempt_to_acquire_leader timeout')
ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None) ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None)
if not ret: if not ret:
@@ -791,85 +863,91 @@ class Etcd3(AbstractEtcd):
return ret return ret
@catch_etcd_errors @catch_etcd_errors
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[str] = None) -> bool:
return self._client.put(self.failover_path, value, mod_revision=index) return bool(self._client.put(self.failover_path, value, mod_revision=version))
@catch_etcd_errors @catch_etcd_errors
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[str] = None) -> bool:
return self._client.put(self.config_path, value, mod_revision=index) return bool(self._client.put(self.config_path, value, mod_revision=version))
@catch_etcd_errors @catch_etcd_errors
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._client.put(self.leader_optime_path, last_lsn) return bool(self._client.put(self.leader_optime_path, last_lsn))
@catch_etcd_errors @catch_etcd_errors
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._client.put(self.status_path, value) return bool(self._client.put(self.status_path, value))
@catch_etcd_errors @catch_etcd_errors
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._client.put(self.failsafe_path, value) return bool(self._client.put(self.failsafe_path, value))
@catch_return_false_exception @catch_return_false_exception
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
retry = self._retry.copy() retry = self._retry.copy()
def _retry(*args, **kwargs): def _retry(*args: Any, **kwargs: Any) -> Any:
kwargs['retry'] = retry kwargs['retry'] = retry
return retry(*args, **kwargs) return retry(*args, **kwargs)
self._run_and_handle_exceptions(self._do_refresh_lease, True, retry=_retry) self._run_and_handle_exceptions(self._do_refresh_lease, True, retry=_retry)
if self._lease: if self._lease and leader.session != self._lease:
cluster = self.cluster retry.ensure_deadline(1, Etcd3Error('update_leader timeout'))
leader_lease = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
if leader_lease != self._lease:
retry.deadline = retry.stoptime - time.time()
if retry.deadline < 1:
raise Etcd3Error('update_leader timeout')
try: fields = {'key': base64_encode(self.leader_path), 'value': base64_encode(self._name), 'lease': self._lease}
self._run_and_handle_exceptions(self._client.put, self.leader_path, # First we try to update lease on existing leader key "hoping" that we still owning it
self._name, self._lease, retry=_retry) compare1 = {'key': fields['key'], 'target': 'VALUE', 'value': fields['value']}
except ReturnFalseException: request_put = {'request_put': fields}
pass # If the first comparison failed we will try to create the new leader key in a transaction
compare2 = {'key': fields['key'], 'target': 'CREATE', 'create_revision': '0'}
request_txn = {'request_txn': {'compare': [compare2], 'success': [request_put]}}
ret = self._run_and_handle_exceptions(self._client.txn, compare1, request_put, request_txn, retry=_retry)
return ret.get('succeeded', False)\
or ret.get('responses', [{}])[0].get('response_txn', {}).get('succeeded', False)
return bool(self._lease) return bool(self._lease)
@catch_etcd_errors @catch_etcd_errors
def initialize(self, create_new=True, sysid=""): def initialize(self, create_new: bool = True, sysid: str = ""):
return self.retry(self._client.put, self.initialize_path, sysid, None, 0 if create_new else None) return self.retry(self._client.put, self.initialize_path, sysid, create_revision='0' if create_new else None)
@catch_etcd_errors @catch_etcd_errors
def _delete_leader(self): def _delete_leader(self) -> bool:
cluster = self.cluster cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name: if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.deleterange(self.leader_path, mod_revision=cluster.leader.index) return self._client.deleterange(self.leader_path, mod_revision=cluster.leader.version)
return True
@catch_etcd_errors @catch_etcd_errors
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self.retry(self._client.deleterange, self.initialize_path) return self.retry(self._client.deleterange, self.initialize_path)
@catch_etcd_errors @catch_etcd_errors
def delete_cluster(self): def delete_cluster(self) -> bool:
return self.retry(self._client.deleteprefix, self.client_path('')) return self.retry(self._client.deleteprefix, self.client_path(''))
@catch_etcd_errors @catch_etcd_errors
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._client.put(self.history_path, value) return bool(self._client.put(self.history_path, value))
@catch_etcd_errors @catch_etcd_errors
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[str] = None) -> Union[str, bool]:
return self.retry(self._client.put, self.sync_path, value, mod_revision=index) return self.retry(self._client.put, self.sync_path, value, mod_revision=version)\
.get('header', {}).get('revision', False)
@catch_etcd_errors @catch_etcd_errors
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[str] = None) -> bool:
return self.retry(self._client.deleterange, self.sync_path, mod_revision=index) return self.retry(self._client.deleterange, self.sync_path, mod_revision=version)
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[str], timeout: float) -> bool:
if self.__do_not_watch: if self.__do_not_watch:
self.__do_not_watch = False self.__do_not_watch = False
return True return True
# We want to give a bit more time to non-leader nodes to synchronize HA loops
if leader_version:
timeout += 0.5
try: try:
return super(Etcd3, self).watch(None, timeout) return super(Etcd3, self).watch(None, timeout)
finally: finally:
+18 -11
View File
@@ -3,9 +3,12 @@ import logging
import random import random
import time import time
from patroni.dcs.zookeeper import ZooKeeper from typing import Any, Callable, Dict, List, Union
from patroni.request import get as requests_get
from patroni.utils import uri from . import Cluster
from .zookeeper import ZooKeeper
from ..request import get as requests_get
from ..utils import uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,11 +17,12 @@ class ExhibitorEnsembleProvider(object):
TIMEOUT = 3.1 TIMEOUT = 3.1
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300): def __init__(self, hosts: List[str], port: int,
uri_path: str = '/exhibitor/v1/cluster/list', poll_interval: int = 300) -> None:
self._exhibitor_port = port self._exhibitor_port = port
self._uri_path = uri_path self._uri_path = uri_path
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._exhibitors = hosts self._exhibitors: List[str] = hosts
self._boot_exhibitors = hosts self._boot_exhibitors = hosts
self._zookeeper_hosts = '' self._zookeeper_hosts = ''
self._next_poll = None self._next_poll = None
@@ -26,7 +30,7 @@ class ExhibitorEnsembleProvider(object):
logger.info('waiting on exhibitor') logger.info('waiting on exhibitor')
time.sleep(5) time.sleep(5)
def poll(self): def poll(self) -> bool:
if self._next_poll and self._next_poll > time.time(): if self._next_poll and self._next_poll > time.time():
return False return False
@@ -36,7 +40,8 @@ class ExhibitorEnsembleProvider(object):
if isinstance(json, dict) and 'servers' in json and 'port' in json: if isinstance(json, dict) and 'servers' in json and 'port' in json:
self._next_poll = time.time() + self._poll_interval self._next_poll = time.time() + self._poll_interval
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])]) servers: List[str] = json['servers']
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(servers)])
if self._zookeeper_hosts != zookeeper_hosts: if self._zookeeper_hosts != zookeeper_hosts:
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts) logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
self._zookeeper_hosts = zookeeper_hosts self._zookeeper_hosts = zookeeper_hosts
@@ -44,7 +49,7 @@ class ExhibitorEnsembleProvider(object):
return True return True
return False return False
def _query_exhibitors(self, exhibitors): def _query_exhibitors(self, exhibitors: List[str]) -> Union[Dict[str, Any], Any]:
random.shuffle(exhibitors) random.shuffle(exhibitors)
for host in exhibitors: for host in exhibitors:
try: try:
@@ -55,18 +60,20 @@ class ExhibitorEnsembleProvider(object):
return None return None
@property @property
def zookeeper_hosts(self): def zookeeper_hosts(self) -> str:
return self._zookeeper_hosts return self._zookeeper_hosts
class Exhibitor(ZooKeeper): class Exhibitor(ZooKeeper):
def __init__(self, config): def __init__(self, config: Dict[str, Any]) -> None:
interval = config.get('poll_interval', 300) interval = config.get('poll_interval', 300)
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval) self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}) super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
if self._ensemble_provider.poll(): if self._ensemble_provider.poll():
self._client.set_hosts(self._ensemble_provider.zookeeper_hosts) self._client.set_hosts(self._ensemble_provider.zookeeper_hosts)
return super(Exhibitor, self)._load_cluster(path, loader) return super(Exhibitor, self)._load_cluster(path, loader)
+323 -230
View File
File diff suppressed because it is too large Load Diff
+105 -90
View File
@@ -10,10 +10,13 @@ from pysyncobj.dns_resolver import globalDnsResolver
from pysyncobj.node import TCPNode from pysyncobj.node import TCPNode
from pysyncobj.transport import TCPTransport, CONNECTION_STATE from pysyncobj.transport import TCPTransport, CONNECTION_STATE
from pysyncobj.utility import TcpUtility from pysyncobj.utility import TcpUtility
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
from ..exceptions import DCSError from ..exceptions import DCSError
from ..utils import validate_directory from ..utils import validate_directory
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,11 +27,12 @@ class RaftError(DCSError):
class _TCPTransport(TCPTransport): class _TCPTransport(TCPTransport):
def __init__(self, syncObj, selfNode, otherNodes): def __init__(self, syncObj: 'DynMemberSyncObj', selfNode: Optional[TCPNode],
otherNodes: Collection[TCPNode]) -> None:
super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes) super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes)
self.setOnUtilityMessageCallback('members', syncObj.getMembers) self.setOnUtilityMessageCallback('members', syncObj.getMembers)
def _connectIfNecessarySingle(self, node): def _connectIfNecessarySingle(self, node: TCPNode) -> bool:
try: try:
return super(_TCPTransport, self)._connectIfNecessarySingle(node) return super(_TCPTransport, self)._connectIfNecessarySingle(node)
except Exception as e: except Exception as e:
@@ -36,7 +40,7 @@ class _TCPTransport(TCPTransport):
return False return False
def resolve_host(self): def resolve_host(self: TCPNode) -> Optional[str]:
return globalDnsResolver().resolve(self.host) return globalDnsResolver().resolve(self.host)
@@ -45,17 +49,19 @@ setattr(TCPNode, 'ip', property(resolve_host))
class SyncObjUtility(object): class SyncObjUtility(object):
def __init__(self, otherNodes, conf, retry_timeout=10): def __init__(self, otherNodes: Collection[Union[str, TCPNode]], conf: SyncObjConf, retry_timeout: int = 10) -> None:
self._nodes = otherNodes self._nodes = otherNodes
self._utility = TcpUtility(conf.password, retry_timeout/max(1, len(otherNodes))) self._utility = TcpUtility(conf.password, retry_timeout / max(1, len(otherNodes)))
self.__node = next(iter(otherNodes), None)
def executeCommand(self, command): def executeCommand(self, command: List[Any]) -> Any:
try: try:
return self._utility.executeCommand(self.__node, command) if self.__node:
return self._utility.executeCommand(self.__node, command)
except Exception: except Exception:
return None return None
def getMembers(self): def getMembers(self) -> Optional[List[str]]:
for self.__node in self._nodes: for self.__node in self._nodes:
response = self.executeCommand(['members']) response = self.executeCommand(['members'])
if response: if response:
@@ -64,7 +70,8 @@ class SyncObjUtility(object):
class DynMemberSyncObj(SyncObj): class DynMemberSyncObj(SyncObj):
def __init__(self, selfAddress, partnerAddrs, conf, retry_timeout=10): def __init__(self, selfAddress: Optional[str], partnerAddrs: Collection[str],
conf: SyncObjConf, retry_timeout: int = 10) -> None:
self.__early_apply_local_log = selfAddress is not None self.__early_apply_local_log = selfAddress is not None
self.applied_local_log = False self.applied_local_log = False
@@ -81,12 +88,12 @@ class DynMemberSyncObj(SyncObj):
thread.daemon = True thread.daemon = True
thread.start() thread.start()
def getMembers(self, args, callback): def getMembers(self, args: Any, callback: Callable[[Any, Any], Any]) -> None:
callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED
if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes] + if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes]
[{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None) + [{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
def _onTick(self, timeToWait=0.0): def _onTick(self, timeToWait: float = 0.0):
super(DynMemberSyncObj, self)._onTick(timeToWait) super(DynMemberSyncObj, self)._onTick(timeToWait)
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes. # The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
@@ -98,15 +105,16 @@ class DynMemberSyncObj(SyncObj):
class KVStoreTTL(DynMemberSyncObj): class KVStoreTTL(DynMemberSyncObj):
def __init__(self, on_ready, on_set, on_delete, **config): def __init__(self, on_ready: Optional[Callable[..., Any]], on_set: Optional[Callable[[str, Dict[str, Any]], None]],
on_delete: Optional[Callable[[str], None]], **config: Any) -> None:
self.__thread = None 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: Dict[str, Dict[str, Any]] = {}
self.set_retry_timeout(int(config.get('retry_timeout') or 10)) self.set_retry_timeout(int(config.get('retry_timeout') or 10))
self_addr = config.get('self_addr') self_addr = config.get('self_addr')
partner_addrs = set(config.get('partner_addrs', [])) partner_addrs: Set[str] = set(config.get('partner_addrs', []))
if config.get('patronictl'): if config.get('patronictl'):
if self_addr: if self_addr:
partner_addrs.add(self_addr) partner_addrs.add(self_addr)
@@ -128,22 +136,22 @@ class KVStoreTTL(DynMemberSyncObj):
onReady=on_ready, dynamicMembershipChange=True) onReady=on_ready, dynamicMembershipChange=True)
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout) super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout)
self.__data = {} self.__data: Dict[str, Dict[str, Any]] = {}
@staticmethod @staticmethod
def __check_requirements(old_value, **kwargs): def __check_requirements(old_value: Dict[str, Any], **kwargs: Any) -> bool:
return ('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value)) and \ return bool(('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value))
('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue']) and \ and ('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue'])
(not kwargs.get('prevIndex') or old_value and old_value['index'] == kwargs['prevIndex']) and (kwargs.get('prevIndex') is None or old_value and old_value['index'] == kwargs['prevIndex']))
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self.__retry_timeout = retry_timeout self.__retry_timeout = retry_timeout
def retry(self, func, *args, **kwargs): def retry(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
event = threading.Event() event = threading.Event()
ret = {'result': None, 'error': -1} ret = {'result': None, 'error': -1}
def callback(result, error): def callback(result: Any, error: Any) -> None:
ret.update(result=result, error=error) ret.update(result=result, error=error)
event.set() event.set()
@@ -167,7 +175,7 @@ class KVStoreTTL(DynMemberSyncObj):
return False return False
@replicated @replicated
def _set(self, key, value, **kwargs): def _set(self, key: str, value: Dict[str, Any], **kwargs: Any) -> Union[bool, Dict[str, Any]]:
old_value = self.__data.get(key, {}) old_value = self.__data.get(key, {})
if not self.__check_requirements(old_value, **kwargs): if not self.__check_requirements(old_value, **kwargs):
return False return False
@@ -179,31 +187,32 @@ class KVStoreTTL(DynMemberSyncObj):
self.__data[key] = value self.__data[key] = value
if self.__on_set: if self.__on_set:
self.__on_set(key, value) self.__on_set(key, value)
return True return value
def set(self, key, value, ttl=None, handle_raft_error=True, **kwargs): def set(self, key: str, value: str, ttl: Optional[int] = None,
handle_raft_error: bool = True, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
old_value = self.__data.get(key, {}) old_value = self.__data.get(key, {})
if not self.__check_requirements(old_value, **kwargs): if not self.__check_requirements(old_value, **kwargs):
return False return False
value = {'value': value, 'updated': time.time()} data: Dict[str, Any] = {'value': value, 'updated': time.time()}
value['created'] = old_value.get('created', value['updated']) data['created'] = old_value.get('created', data['updated'])
if ttl: if ttl:
value['expire'] = value['updated'] + ttl data['expire'] = data['updated'] + ttl
try: try:
return self.retry(self._set, key, value, **kwargs) return self.retry(self._set, key, data, **kwargs)
except RaftError: except RaftError:
if not handle_raft_error: if not handle_raft_error:
raise raise
return False return False
def __pop(self, key): def __pop(self, key: str) -> None:
self.__data.pop(key) self.__data.pop(key)
if self.__on_delete: if self.__on_delete:
self.__on_delete(key) self.__on_delete(key)
@replicated @replicated
def _delete(self, key, recursive=False, **kwargs): def _delete(self, key: str, recursive: bool = False, **kwargs: Any) -> bool:
if recursive: if recursive:
for k in list(self.__data.keys()): for k in list(self.__data.keys()):
if k.startswith(key): if k.startswith(key):
@@ -214,7 +223,7 @@ class KVStoreTTL(DynMemberSyncObj):
self.__pop(key) self.__pop(key)
return True return True
def delete(self, key, recursive=False, **kwargs): def delete(self, key: str, recursive: bool = False, **kwargs: Any) -> bool:
if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs): if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs):
return False return False
try: try:
@@ -223,32 +232,32 @@ class KVStoreTTL(DynMemberSyncObj):
return False return False
@staticmethod @staticmethod
def __values_match(old, new): def __values_match(old: Dict[str, Any], new: Dict[str, Any]) -> bool:
return all(old.get(n) == new.get(n) for n in ('created', 'updated', 'expire', 'value')) return all(old.get(n) == new.get(n) for n in ('created', 'updated', 'expire', 'value'))
@replicated @replicated
def _expire(self, key, value, callback=None): def _expire(self, key: str, value: Dict[str, Any], callback: Optional[Callable[..., Any]] = None) -> None:
current = self.__data.get(key) current = self.__data.get(key)
if current and self.__values_match(current, value): if current and self.__values_match(current, value):
self.__pop(key) self.__pop(key)
def __expire_keys(self): def __expire_keys(self) -> None:
for key, value in self.__data.items(): for key, value in self.__data.items():
if value and 'expire' in value and value['expire'] <= time.time() and \ if value and 'expire' in value and value['expire'] <= time.time() and \
not (key in self.__limb and self.__values_match(self.__limb[key], value)): not (key in self.__limb and self.__values_match(self.__limb[key], value)):
self.__limb[key] = value self.__limb[key] = value
def callback(*args): def callback(*args: Any) -> None:
if key in self.__limb and self.__values_match(self.__limb[key], value): if key in self.__limb and self.__values_match(self.__limb[key], value):
self.__limb.pop(key) self.__limb.pop(key)
self._expire(key, value, callback=callback) self._expire(key, value, callback=callback)
def get(self, key, recursive=False): def get(self, key: str, recursive: bool = False) -> Union[None, Dict[str, Any], Dict[str, Dict[str, Any]]]:
if not recursive: if not recursive:
return self.__data.get(key) return self.__data.get(key)
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: float = 0.0) -> None:
super(KVStoreTTL, self)._onTick(timeToWait) super(KVStoreTTL, self)._onTick(timeToWait)
if self._isLeader(): if self._isLeader():
@@ -256,17 +265,17 @@ class KVStoreTTL(DynMemberSyncObj):
else: else:
self.__limb.clear() self.__limb.clear()
def _autoTickThread(self): def _autoTickThread(self) -> None:
self.__destroying = False self.__destroying = False
while not self.__destroying: while not self.__destroying:
self.doTick(self.conf.autoTickPeriod) self.doTick(self.conf.autoTickPeriod)
def startAutoTick(self): def startAutoTick(self) -> None:
self.__thread = threading.Thread(target=self._autoTickThread) self.__thread = threading.Thread(target=self._autoTickThread)
self.__thread.daemon = True self.__thread.daemon = True
self.__thread.start() self.__thread.start()
def destroy(self): def destroy(self) -> None:
if self.__thread: if self.__thread:
self.__destroying = True self.__destroying = True
self.__thread.join() self.__thread.join()
@@ -275,7 +284,7 @@ class KVStoreTTL(DynMemberSyncObj):
class Raft(AbstractDCS): class Raft(AbstractDCS):
def __init__(self, config): def __init__(self, config: Dict[str, Any]) -> None:
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)
@@ -290,7 +299,7 @@ class Raft(AbstractDCS):
else: else:
logger.info('waiting on raft') logger.info('waiting on raft')
def _on_set(self, key, value): def _on_set(self, key: str, value: Dict[str, Any]) -> None:
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 \
@@ -298,29 +307,29 @@ class Raft(AbstractDCS):
key in (self.config_path, self.sync_path): key in (self.config_path, self.sync_path):
self.event.set() self.event.set()
def _on_delete(self, key): def _on_delete(self, key: str) -> None:
if key == self.leader_path: if key == self.leader_path:
self.event.set() self.event.set()
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
self._ttl = ttl self._ttl = ttl
@property @property
def ttl(self): def ttl(self) -> int:
return self._ttl return self._ttl
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self._sync_obj.set_retry_timeout(retry_timeout) self._sync_obj.set_retry_timeout(retry_timeout)
def reload_config(self, config): def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
super(Raft, self).reload_config(config) super(Raft, self).reload_config(config)
globalDnsResolver().setTimeouts(self.ttl, self.loop_wait) globalDnsResolver().setTimeouts(self.ttl, self.loop_wait)
@staticmethod @staticmethod
def member(key, value): def member(key: str, value: Dict[str, Any]) -> Member:
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'])
def _cluster_from_nodes(self, nodes): def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['value'] initialize = initialize and initialize['value']
@@ -348,7 +357,7 @@ class Raft(AbstractDCS):
slots = None slots = None
try: try:
last_lsn = int(last_lsn) last_lsn = int(last_lsn or '')
except Exception: except Exception:
last_lsn = 0 last_lsn = 0
@@ -380,80 +389,86 @@ class Raft(AbstractDCS):
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _cluster_loader(self, path): def _cluster_loader(self, path: str) -> Cluster:
response = self._sync_obj.get(path, recursive=True) response = self._sync_obj.get(path, recursive=True)
if not response: if not response:
return Cluster.empty() return Cluster.empty()
nodes = {key[len(path):]: value for key, value in response.items()} nodes = {key[len(path):]: value for key, value in response.items()}
return self._cluster_from_nodes(nodes) return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path): def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters = defaultdict(dict) clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
response = self._sync_obj.get(path, recursive=True) response = self._sync_obj.get(path, recursive=True)
for key, value in response.items(): for key, value in (response or {}).items():
key = key[len(path):].split('/', 1) key = key[len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]): if len(key) == 2 and citus_group_re.match(key[0]):
clusters[int(key[0])][key[1]] = value clusters[int(key[0])][key[1]] = value
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
return loader(path) return loader(path)
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1) return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1) is not False
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._sync_obj.set(self.status_path, value, timeout=1) return self._sync_obj.set(self.status_path, value, timeout=1) is not False
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._sync_obj.set(self.failsafe_path, value, timeout=1) return self._sync_obj.set(self.failsafe_path, value, timeout=1) is not False
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
handle_raft_error=False, prevValue=self._name) handle_raft_error=False, prevValue=self._name) is not False
if not ret and self._sync_obj.get(self.leader_path) is None: if not ret and self._sync_obj.get(self.leader_path) is None:
ret = self.attempt_to_acquire_leader() ret = self.attempt_to_acquire_leader()
return ret return ret
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, handle_raft_error=False, prevExist=False) return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
handle_raft_error=False, prevExist=False) is not False
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._sync_obj.set(self.failover_path, value, prevIndex=index) return self._sync_obj.set(self.failover_path, value, prevIndex=version) is not False
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._sync_obj.set(self.config_path, value, prevIndex=index) return self._sync_obj.set(self.config_path, value, prevIndex=version) is not False
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
data = json.dumps(data, separators=(',', ':')) value = json.dumps(data, separators=(',', ':'))
return self._sync_obj.set(self.member_path, data, self._ttl, timeout=2) return self._sync_obj.set(self.member_path, value, self._ttl, timeout=2) is not False
def take_leader(self): def take_leader(self) -> bool:
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl) return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl) is not False
def initialize(self, create_new=True, sysid=''): def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new)) return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new)) is not False
def _delete_leader(self): def _delete_leader(self) -> bool:
return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1) return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1)
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self._sync_obj.delete(self.initialize_path) return self._sync_obj.delete(self.initialize_path)
def delete_cluster(self): def delete_cluster(self) -> bool:
return self._sync_obj.delete(self.client_path(''), recursive=True) return self._sync_obj.delete(self.client_path(''), recursive=True)
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._sync_obj.set(self.history_path, value) return self._sync_obj.set(self.history_path, value) is not False
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self._sync_obj.set(self.sync_path, value, prevIndex=index) ret = self._sync_obj.set(self.sync_path, value, prevIndex=version)
if isinstance(ret, dict):
return ret['index']
return ret
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self._sync_obj.delete(self.sync_path, prevIndex=index) return self._sync_obj.delete(self.sync_path, prevIndex=version)
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
try: try:
return super(Raft, self).watch(leader_index, timeout) return super(Raft, self).watch(leader_version, timeout)
finally: finally:
self.event.clear() self.event.clear()
+119 -109
View File
@@ -1,18 +1,22 @@
import json import json
import logging import logging
import select import select
import socket
import time import time
from kazoo.client import KazooClient, KazooState, KazooRetry from kazoo.client import KazooClient, KazooState, KazooRetry
from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError
from kazoo.handlers.threading import SequentialThreadingHandler from kazoo.handlers.threading import AsyncResult, SequentialThreadingHandler
from kazoo.protocol.states import KeeperState from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
from kazoo.retry import RetryFailedError from kazoo.retry import RetryFailedError
from kazoo.security import make_acl from kazoo.security import ACL, make_acl
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
from ..exceptions import DCSError from ..exceptions import DCSError
from ..utils import deep_compare from ..utils import deep_compare
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,14 +27,14 @@ class ZooKeeperError(DCSError):
class PatroniSequentialThreadingHandler(SequentialThreadingHandler): class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
def __init__(self, connect_timeout): def __init__(self, connect_timeout: Union[int, float]) -> None:
super(PatroniSequentialThreadingHandler, self).__init__() super(PatroniSequentialThreadingHandler, self).__init__()
self.set_connect_timeout(connect_timeout) self.set_connect_timeout(connect_timeout)
def set_connect_timeout(self, connect_timeout): def set_connect_timeout(self, connect_timeout: Union[int, float]) -> None:
self._connect_timeout = max(1.0, connect_timeout/2.0) # try to connect to zookeeper node during loop_wait/2 self._connect_timeout = max(1.0, connect_timeout / 2.0) # try to connect to zookeeper node during loop_wait/2
def create_connection(self, *args, **kwargs): def create_connection(self, *args: Any, **kwargs: Any) -> socket.socket:
"""This method is trying to establish connection with one of the zookeeper nodes. """This method is trying to establish connection with one of the zookeeper nodes.
Somehow strategy "fail earlier and retry more often" works way better comparing to Somehow strategy "fail earlier and retry more often" works way better comparing to
the original strategy "try to connect with specified timeout". the original strategy "try to connect with specified timeout".
@@ -41,16 +45,16 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
:param args: always contains `tuple(host, port)` as the first element and could contain :param args: always contains `tuple(host, port)` as the first element and could contain
`connect_timeout` (negotiated session timeout) as the second element.""" `connect_timeout` (negotiated session timeout) as the second element."""
args = list(args) args_list: List[Any] = list(args)
if len(args) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method if len(args_list) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method
kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout*10)/10.0) kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout * 10) / 10.0)
elif len(args) == 1: elif len(args_list) == 1:
args.append(self._connect_timeout) args_list.append(self._connect_timeout)
else: else:
args[1] = max(self._connect_timeout, args[1]/10.0) args_list[1] = max(self._connect_timeout, args_list[1] / 10.0)
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs) return super(PatroniSequentialThreadingHandler, self).create_connection(*args_list, **kwargs)
def select(self, *args, **kwargs): def select(self, *args: Any, **kwargs: Any) -> Any:
""" """
Python 3.XY may raise following exceptions if select/poll are called with an invalid socket: Python 3.XY may raise following exceptions if select/poll are called with an invalid socket:
- `ValueError`: because fd == -1 - `ValueError`: because fd == -1
@@ -68,7 +72,7 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
class PatroniKazooClient(KazooClient): class PatroniKazooClient(KazooClient):
def _call(self, request, async_object): def _call(self, request: Tuple[Any], async_object: AsyncResult) -> Optional[bool]:
# Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if # 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 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. # The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued.
@@ -82,7 +86,7 @@ class PatroniKazooClient(KazooClient):
class ZooKeeper(AbstractDCS): class ZooKeeper(AbstractDCS):
def __init__(self, config): def __init__(self, config: Dict[str, Any]) -> None:
super(ZooKeeper, self).__init__(config) super(ZooKeeper, self).__init__(config)
hosts = config.get('hosts', []) hosts = config.get('hosts', [])
@@ -94,17 +98,18 @@ class ZooKeeper(AbstractDCS):
kwargs = {v: config[k] for k, v in mapping.items() if k in config} kwargs = {v: config[k] for k, v in mapping.items() if k in config}
if 'set_acls' in config: if 'set_acls' in config:
kwargs['default_acl'] = [] default_acl: List[ACL] = []
for principal, permissions in config['set_acls'].items(): for principal, permissions in config['set_acls'].items():
normalizedPermissions = [p.upper() for p in permissions] normalizedPermissions = [p.upper() for p in permissions]
kwargs['default_acl'].append(make_acl(scheme='x509', default_acl.append(make_acl(scheme='x509',
credential=principal, credential=principal,
read='READ' in normalizedPermissions, read='READ' in normalizedPermissions,
write='WRITE' in normalizedPermissions, write='WRITE' in normalizedPermissions,
create='CREATE' in normalizedPermissions, create='CREATE' in normalizedPermissions,
delete='DELETE' in normalizedPermissions, delete='DELETE' in normalizedPermissions,
admin='ADMIN' in normalizedPermissions, admin='ADMIN' in normalizedPermissions,
all='ALL' in normalizedPermissions)) all='ALL' in normalizedPermissions))
kwargs['default_acl'] = default_acl
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']), self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1, timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
@@ -112,16 +117,16 @@ class ZooKeeper(AbstractDCS):
deadline=config['retry_timeout'], 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: bool = True
self._fetch_status = True self._fetch_status: bool = True
self.__last_member_data = None self.__last_member_data: Optional[Dict[str, Any]] = None
self._orig_kazoo_connect = self._client._connection._connect self._orig_kazoo_connect = self._client._connection._connect
self._client._connection._connect = self._kazoo_connect self._client._connection._connect = self._kazoo_connect
self._client.start() self._client.start()
def _kazoo_connect(self, *args): def _kazoo_connect(self, *args: Any) -> Tuple[Union[int, float], Union[int, float]]:
"""Kazoo is using Ping's to determine health of connection to zookeeper. If there is no """Kazoo is using Ping's to determine health of connection to zookeeper. If there is no
response on Ping after Ping interval (1/2 from read_timeout) it will consider current response on Ping after Ping interval (1/2 from read_timeout) it will consider current
connection dead and try to connect to another node. Without this "magic" it was taking connection dead and try to connect to another node. Without this "magic" it was taking
@@ -134,32 +139,30 @@ class ZooKeeper(AbstractDCS):
`write_leader_optime()` methods, which also may hang...""" `write_leader_optime()` methods, which also may hang..."""
ret = self._orig_kazoo_connect(*args) ret = self._orig_kazoo_connect(*args)
return max(self.loop_wait - 2, 2)*1000, ret[1] return max(self.loop_wait - 2, 2) * 1000, ret[1]
def session_listener(self, state): def session_listener(self, state: str) -> None:
if state in [KazooState.SUSPENDED, KazooState.LOST]: if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None) self.cluster_watcher(None)
def status_watcher(self, event): def status_watcher(self, event: Optional[WatchedEvent]) -> None:
self._fetch_status = True self._fetch_status = True
self.event.set() self.event.set()
def cluster_watcher(self, event): def cluster_watcher(self, event: Optional[WatchedEvent]) -> None:
self._fetch_cluster = True self._fetch_cluster = True
if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')): if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.status_watcher(event) self.status_watcher(event)
def members_watcher(self, event): def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
self._fetch_cluster = True
def reload_config(self, config):
self.set_retry_timeout(config['retry_timeout']) self.set_retry_timeout(config['retry_timeout'])
loop_wait = config['loop_wait'] loop_wait = config['loop_wait']
loop_wait_changed = self._loop_wait != loop_wait loop_wait_changed = self._loop_wait != loop_wait
self._loop_wait = loop_wait self._loop_wait = loop_wait
self._client.handler.set_connect_timeout(loop_wait) if isinstance(self._client.handler, PatroniSequentialThreadingHandler):
self._client.handler.set_connect_timeout(loop_wait)
# We need to reestablish connection to zookeeper if we want to change # We need to reestablish connection to zookeeper if we want to change
# read_timeout (and Ping interval respectively), because read_timeout # read_timeout (and Ping interval respectively), because read_timeout
@@ -170,7 +173,7 @@ class ZooKeeper(AbstractDCS):
if not self.set_ttl(config['ttl']) and loop_wait_changed: if not self.set_ttl(config['ttl']) and loop_wait_changed:
self._client._connection._socket.close() self._client._connection._socket.close()
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
"""It is not possible to change ttl (session_timeout) in zookeeper without """It is not possible to change ttl (session_timeout) in zookeeper without
destroying old session and creating the new one. This method returns `!True` destroying old session and creating the new one. This method returns `!True`
if session_timeout has been changed (`restart()` has been called).""" if session_timeout has been changed (`restart()` has been called)."""
@@ -181,21 +184,23 @@ class ZooKeeper(AbstractDCS):
return True return True
@property @property
def ttl(self): def ttl(self) -> int:
return self._client._session_timeout / 1000.0 return int(self._client._session_timeout / 1000.0)
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry
retry.deadline = retry_timeout retry.deadline = retry_timeout
def get_node(self, key, watch=None): def get_node(
self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None
) -> Optional[Tuple[str, ZnodeStat]]:
try: try:
ret = self._client.get(key, watch) ret = self._client.get(key, watch)
return (ret[0].decode('utf-8'), ret[1]) return (ret[0].decode('utf-8'), ret[1])
except NoNodeError: except NoNodeError:
return None return None
def get_status(self, path, leader): def get_status(self, path: str, leader: Optional[Leader]) -> Tuple[int, Optional[Dict[str, int]]]:
watch = self.status_watcher if not leader or leader.name != self._name else None watch = self.status_watcher if not leader or leader.name != self._name else None
status = self.get_node(path + self._STATUS, watch) status = self.get_node(path + self._STATUS, watch)
@@ -212,7 +217,7 @@ class ZooKeeper(AbstractDCS):
slots = None slots = None
try: try:
last_lsn = int(last_lsn) last_lsn = int(last_lsn or '')
except Exception: except Exception:
last_lsn = 0 last_lsn = 0
@@ -220,24 +225,24 @@ class ZooKeeper(AbstractDCS):
return last_lsn, slots return last_lsn, slots
@staticmethod @staticmethod
def member(name, value, znode): def member(name: str, value: str, znode: ZnodeStat) -> Member:
return Member.from_node(znode.version, name, znode.ephemeralOwner, value) return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
def get_children(self, key, watch=None): def get_children(self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None) -> List[str]:
try: try:
return self._client.get_children(key, watch) return self._client.get_children(key, watch)
except NoNodeError: except NoNodeError:
return [] return []
def load_members(self, path): def load_members(self, path: str) -> List[Member]:
members = [] members: List[Member] = []
for member in self.get_children(path + self._MEMBERS, self.cluster_watcher): for member in self.get_children(path + self._MEMBERS, self.cluster_watcher):
data = self.get_node(path + self._MEMBERS + member) data = self.get_node(path + self._MEMBERS + member)
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
def _cluster_loader(self, path): def _cluster_loader(self, path: str) -> Cluster:
self._fetch_cluster = False self._fetch_cluster = False
self.event.clear() self.event.clear()
nodes = set(self.get_children(path, self.cluster_watcher)) nodes = set(self.get_children(path, self.cluster_watcher))
@@ -268,7 +273,7 @@ class ZooKeeper(AbstractDCS):
member = Member(-1, leader[0], None, {}) member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0] member = ([m for m in members if m.name == leader[0]] or [member])[0]
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.version == -1
# get last known leader lsn and slots # get last known leader lsn and slots
last_lsn, slots = self.get_status(path, leader) last_lsn, slots = self.get_status(path, leader)
@@ -286,9 +291,9 @@ class ZooKeeper(AbstractDCS):
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _citus_cluster_loader(self, path): def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
fetch_cluster = False fetch_cluster = False
ret = {} ret: Dict[int, Cluster] = {}
for node in self.get_children(path, self.cluster_watcher): for node in self.get_children(path, self.cluster_watcher):
if citus_group_re.match(node): if citus_group_re.match(node):
ret[int(node)] = self._cluster_loader(path + node + '/') ret[int(node)] = self._cluster_loader(path + node + '/')
@@ -296,7 +301,9 @@ class ZooKeeper(AbstractDCS):
self._fetch_cluster = fetch_cluster self._fetch_cluster = fetch_cluster
return ret return ret
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
cluster = self.cluster if path == self._base_path + '/' else None cluster = self.cluster if path == self._base_path + '/' else None
if self._fetch_cluster or cluster is None: if self._fetch_cluster or cluster is None:
try: try:
@@ -315,18 +322,18 @@ class ZooKeeper(AbstractDCS):
try: try:
last_lsn, slots = self.get_status(self.client_path(''), cluster.leader) last_lsn, slots = self.get_status(self.client_path(''), cluster.leader)
self.event.clear() self.event.clear()
cluster = list(cluster) new_cluster: List[Any] = list(cluster)
cluster[3] = last_lsn new_cluster[3] = last_lsn
cluster[8] = slots new_cluster[8] = slots
cluster = Cluster(*cluster) cluster = Cluster(*new_cluster)
except Exception: except Exception:
pass pass
return cluster return cluster
def _bypass_caches(self): def _bypass_caches(self) -> None:
self._fetch_cluster = True self._fetch_cluster = True
def _create(self, path, value, retry=False, ephemeral=False): def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool:
try: try:
if retry: if retry:
self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral) self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral)
@@ -337,7 +344,7 @@ class ZooKeeper(AbstractDCS):
logger.exception('Failed to create %s', path) logger.exception('Failed to create %s', path)
return False return False
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
try: try:
self._client.retry(self._client.create, self.leader_path, self._name.encode('utf-8'), self._client.retry(self._client.create, self.leader_path, self._name.encode('utf-8'),
makepath=True, ephemeral=True) makepath=True, ephemeral=True)
@@ -350,47 +357,50 @@ class ZooKeeper(AbstractDCS):
logger.info('Could not take out TTL lock') logger.info('Could not take out TTL lock')
return False return False
def _set_or_create(self, key, value, index=None, retry=False, do_not_create_empty=False): def _set_or_create(self, key: str, value: str, version: Optional[int] = None,
value = value.encode('utf-8') retry: bool = False, do_not_create_empty: bool = False) -> Union[int, bool]:
value_bytes = value.encode('utf-8')
try: try:
if retry: if retry:
self._client.retry(self._client.set, key, value, version=index or -1) ret = self._client.retry(self._client.set, key, value_bytes, version=version or -1)
else: else:
self._client.set_async(key, value, version=index or -1).get(timeout=1) ret = self._client.set_async(key, value_bytes, version=version or -1).get(timeout=1)
return True return ret.version
except NoNodeError: except NoNodeError:
if do_not_create_empty and not value: if do_not_create_empty and not value_bytes:
return True return True
elif index is None: elif version is None:
return self._create(key, value, retry) if self._create(key, value_bytes, retry):
return 0
else: else:
return False return False
except Exception: except Exception:
logger.exception('Failed to update %s', key) logger.exception('Failed to update %s', key)
return False return False
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._set_or_create(self.failover_path, value, index) return self._set_or_create(self.failover_path, value, version) is not False
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._set_or_create(self.config_path, value, index, retry=True) return self._set_or_create(self.config_path, value, version, retry=True) is not False
def initialize(self, create_new=True, sysid=""): def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
sysid = sysid.encode('utf-8') sysid_bytes = sysid.encode('utf-8')
return self._create(self.initialize_path, sysid, retry=True) if create_new \ return self._create(self.initialize_path, sysid_bytes, retry=True) if create_new \
else self._client.retry(self._client.set, self.initialize_path, sysid) else self._client.retry(self._client.set, self.initialize_path, sysid_bytes)
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
cluster = self.cluster cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False) member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
member_data = self.__last_member_data or member and member.data member_data = self.__last_member_data or member and member.data
# We want to notify leader if some important fields in the member key changed by removing ZNode # We want to notify leader if some important fields in the member key changed by removing ZNode
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or if member and (self._client.client_id is not None and member.session != self._client.client_id[0]
not (deep_compare(member_data.get('tags', {}), data.get('tags', {})) and or not (member_data and deep_compare(member_data.get('tags', {}), data.get('tags', {}))
(member_data.get('state') == data.get('state') or and (member_data.get('state') == data.get('state')
'running' not in (member_data.get('state'), data.get('state'))) and or 'running' not in (member_data.get('state'), data.get('state')))
member_data.get('version') == data.get('version') and and member_data.get('version') == data.get('version')
member_data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))): and member_data.get('checkpoint_after_promote')
== data.get('checkpoint_after_promote'))):
try: try:
self._client.delete_async(self.member_path).get(timeout=1) self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError: except NoNodeError:
@@ -400,7 +410,7 @@ class ZooKeeper(AbstractDCS):
member = None member = None
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8') encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member: if member and member_data:
if deep_compare(data, member_data): if deep_compare(data, member_data):
return True return True
else: else:
@@ -421,22 +431,20 @@ class ZooKeeper(AbstractDCS):
return False return False
def take_leader(self): def take_leader(self) -> bool:
return self.attempt_to_acquire_leader() return self.attempt_to_acquire_leader()
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._set_or_create(self.leader_optime_path, last_lsn) return self._set_or_create(self.leader_optime_path, last_lsn) is not False
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._set_or_create(self.status_path, value) return self._set_or_create(self.status_path, value) is not False
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._set_or_create(self.failsafe_path, value) return self._set_or_create(self.failsafe_path, value) is not False
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
cluster = self.cluster if self._client.client_id and self._client.client_id[0] != leader.session:
session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
if self._client.client_id and self._client.client_id[0] != session:
logger.warning('Recreating the leader ZNode due to ownership mismatch') logger.warning('Recreating the leader ZNode due to ownership mismatch')
try: try:
self._client.retry(self._client.delete, self.leader_path) self._client.retry(self._client.delete, self.leader_path)
@@ -458,38 +466,40 @@ class ZooKeeper(AbstractDCS):
return False return False
return True return True
def _delete_leader(self): def _delete_leader(self) -> bool:
self._client.restart() self._client.restart()
return True return True
def _cancel_initialization(self): def _cancel_initialization(self) -> None:
node = self.get_node(self.initialize_path) node = self.get_node(self.initialize_path)
if node: if node:
self._client.delete(self.initialize_path, version=node[1].version) self._client.delete(self.initialize_path, version=node[1].version)
def cancel_initialization(self): def cancel_initialization(self) -> bool:
try: try:
self._client.retry(self._cancel_initialization) self._client.retry(self._cancel_initialization)
return True
except Exception: except Exception:
logger.exception("Unable to delete initialize key") logger.exception("Unable to delete initialize key")
return False
def delete_cluster(self): def delete_cluster(self) -> bool:
try: try:
return self._client.retry(self._client.delete, self.client_path(''), recursive=True) return self._client.retry(self._client.delete, self.client_path(''), recursive=True)
except NoNodeError: except NoNodeError:
return True return True
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._set_or_create(self.history_path, value) return self._set_or_create(self.history_path, value) is not False
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self._set_or_create(self.sync_path, value, index, retry=True, do_not_create_empty=True) return self._set_or_create(self.sync_path, value, version, retry=True, do_not_create_empty=True)
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self.set_sync_state_value("{}", index) return self.set_sync_state_value("{}", version) is not False
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
ret = super(ZooKeeper, self).watch(leader_index, timeout + 0.5) ret = super(ZooKeeper, self).watch(leader_version, timeout + 0.5)
if ret and not self._fetch_status: 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
+27 -9
View File
@@ -1,37 +1,55 @@
"""Implement high-level Patroni exceptions.
More specific exceptions can be found in other modules, as subclasses of any exception defined in this module.
"""
from typing import Any
class PatroniException(Exception): class PatroniException(Exception):
"""Parent class for all kind of Patroni exceptions.
"""Parent class for all kind of exceptions related to selected distributed configuration store""" :ivar value: description of the exception.
"""
def __init__(self, value): def __init__(self, value: Any) -> None:
"""Create a new instance of :class:`PatroniException` with the given description.
:param value: description of the exception.
"""
self.value = value self.value = value
def __str__(self):
"""
>>> str(PatroniException('foo'))
"'foo'"
"""
return repr(self.value)
class PatroniFatalException(PatroniException): class PatroniFatalException(PatroniException):
"""Catastrophic exception that prevents Patroni from performing its job."""
pass pass
class PostgresException(PatroniException): class PostgresException(PatroniException):
"""Any exception related with Postgres management."""
pass pass
class DCSError(PatroniException): class DCSError(PatroniException):
"""Parent class for all kind of DCS related exceptions."""
pass pass
class PostgresConnectionException(PostgresException): class PostgresConnectionException(PostgresException):
"""Any problem faced while connecting to a Postgres instance."""
pass pass
class WatchdogError(PatroniException): class WatchdogError(PatroniException):
"""Any problem faced while managing a watchdog device."""
pass pass
class ConfigParseError(PatroniException): class ConfigParseError(PatroniException):
"""Any issue identified while loading or validating the Patroni configuration."""
pass pass
+95
View File
@@ -0,0 +1,95 @@
"""Helper object that helps with figuring out file and directory permissions based on permissions of PGDATA.
:var logger: logger of this module.
:var pg_perm: instance of the :class:`__FilePermissions` object.
"""
import logging
import os
import stat
logger = logging.getLogger(__name__)
class __FilePermissions:
"""Helper class for managing permissions of directories and files under PGDATA.
Execute :meth:`set_permissions_from_data_directory` to figure out which permissions should be used for files and
directories under PGDATA based on permissions of PGDATA root directory.
"""
# Mode mask for data directory permissions that only allows the owner to
# read/write directories and files -- mask 077.
__PG_MODE_MASK_OWNER = stat.S_IRWXG | stat.S_IRWXO
# Mode mask for data directory permissions that also allows group read/execute -- mask 027.
__PG_MODE_MASK_GROUP = stat.S_IWGRP | stat.S_IRWXO
# Default mode for creating directories -- mode 700.
__PG_DIR_MODE_OWNER = stat.S_IRWXU
# Mode for creating directories that allows group read/execute -- mode 750.
__PG_DIR_MODE_GROUP = stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP
# Default mode for creating files -- mode 600.
__PG_FILE_MODE_OWNER = stat.S_IRUSR | stat.S_IWUSR
# Mode for creating files that allows group read -- mode 640.
__PG_FILE_MODE_GROUP = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
def __init__(self) -> None:
"""Create a :class:`__FilePermissions` object and set default permissions."""
self.__set_owner_permissions()
self.__set_umask()
def __set_umask(self) -> None:
"""Set umask value based on calculations.
.. note::
Should only be called once either :meth:`__set_owner_permissions`
or :meth:`__set_group_permissions` has been executed.
"""
try:
os.umask(self.__pg_mode_mask)
except Exception as e:
logger.error('Can not set umask to %03o: %r', self.__pg_mode_mask, e)
def __set_owner_permissions(self) -> None:
"""Make directories/files accessible only by the owner."""
self.__pg_dir_create_mode = self.__PG_DIR_MODE_OWNER
self.__pg_file_create_mode = self.__PG_FILE_MODE_OWNER
self.__pg_mode_mask = self.__PG_MODE_MASK_OWNER
def __set_group_permissions(self) -> None:
"""Make directories/files accessible by the owner and readable by group."""
self.__pg_dir_create_mode = self.__PG_DIR_MODE_GROUP
self.__pg_file_create_mode = self.__PG_FILE_MODE_GROUP
self.__pg_mode_mask = self.__PG_MODE_MASK_GROUP
def set_permissions_from_data_directory(self, data_dir: str) -> None:
"""Set new permissions based on provided *data_dir*.
:param data_dir: reference to PGDATA to calculate permissions from.
"""
try:
st = os.stat(data_dir)
if (st.st_mode & self.__PG_DIR_MODE_GROUP) == self.__PG_DIR_MODE_GROUP:
self.__set_group_permissions()
else:
self.__set_owner_permissions()
except Exception as e:
logger.error('Can not check permissions on %s: %r', data_dir, e)
else:
self.__set_umask()
@property
def dir_create_mode(self) -> int:
"""Directory permissions."""
return self.__pg_dir_create_mode
@property
def file_create_mode(self) -> int:
"""File permissions."""
return self.__pg_file_create_mode
pg_perm = __FilePermissions()
+419 -323
View File
File diff suppressed because it is too large Load Diff
+176 -30
View File
@@ -1,3 +1,8 @@
"""Patroni logging facilities.
Daemon processes will use a 2-step logging handler. Whenever a log message is issued it is initially enqueued in-memory
and is later asynchronously flushed by a thread to the final destination.
"""
import logging import logging
import os import os
import sys import sys
@@ -8,38 +13,81 @@ from patroni.utils import deep_compare
from queue import Queue, Full from queue import Queue, Full
from threading import Lock, Thread from threading import Lock, Thread
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def debug_exception(logger_obj, msg, *args, **kwargs): def debug_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
"""Add full stack trace info to debug log messages and partial to others.
Handle :func:`exception` calls for *self*.
.. note::
* If *self* log level is set to ``DEBUG``, then issue a ``DEBUG`` message with the complete stack trace;
* If *self* log level is ``INFO`` or higher, then issue an ``ERROR`` message with only the last line of
the stack trace.
:param self: logger for which :func:`exception` will be processed.
:param msg: the message related to the exception to be logged.
:param args: positional arguments to be passed to :func:`self.debug` or :func:`loger_obj.error`.
:param kwargs: keyword arguments to be passed to :func:`self.debug` or :func:`loger_obj.error`.
"""
kwargs.pop("exc_info", False) kwargs.pop("exc_info", False)
if logger_obj.isEnabledFor(logging.DEBUG): if self.isEnabledFor(logging.DEBUG):
logger_obj.debug(msg, *args, exc_info=True, **kwargs) self.debug(msg, *args, exc_info=True, **kwargs)
else: else:
msg = "{0}, DETAIL: '{1}'".format(msg, sys.exc_info()[1]) msg = "{0}, DETAIL: '{1}'".format(msg, sys.exc_info()[1])
logger_obj.error(msg, *args, exc_info=False, **kwargs) self.error(msg, *args, exc_info=False, **kwargs)
def error_exception(logger_obj, msg, *args, **kwargs): def error_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
"""Add full stack trace info to error messages.
Handle :func:`exception` calls for *self*.
.. note::
* By default issue an ``ERROR`` message with the complete stack trace. If you do not want to show the complete
stack trace, call with ``exc_info=False``.
:param self: logger for which :func:`exception` will be processed.
:param msg: the message related to the exception to be logged.
:param args: positional arguments to be passed to :func:`loger_obj.error`.
:param kwargs: keyword arguments to be passed to :func:`loger_obj.error`.
"""
exc_info = kwargs.pop("exc_info", True) exc_info = kwargs.pop("exc_info", True)
logger_obj.error(msg, *args, exc_info=exc_info, **kwargs) self.error(msg, *args, exc_info=exc_info, **kwargs)
class QueueHandler(logging.Handler): class QueueHandler(logging.Handler):
"""Queue-based logging handler.
def __init__(self): :ivar queue: queue to hold log messages that are pending to be flushed to the final destination.
logging.Handler.__init__(self) """
self.queue = Queue()
def __init__(self) -> None:
"""Queue initialised and initial records_lost established."""
super().__init__()
self.queue: Queue[Union[logging.LogRecord, None]] = Queue()
self._records_lost = 0 self._records_lost = 0
def _put_record(self, record): def _put_record(self, record: logging.LogRecord) -> None:
"""Asynchronously enqueue a log record.
:param record: the record to be logged.
"""
self.format(record) self.format(record)
record.msg = record.message record.msg = record.message
record.args = None record.args = None
record.exc_info = None record.exc_info = None
self.queue.put_nowait(record) self.queue.put_nowait(record)
def _try_to_report_lost_records(self): def _try_to_report_lost_records(self) -> None:
"""Report the number of log messages that have been lost and reset the counter.
.. note::
It will issue an ``WARNING`` message in the logs with the number of lost log messages.
"""
if self._records_lost: if self._records_lost:
try: try:
record = _LOGGER.makeRecord(_LOGGER.name, logging.WARNING, __file__, 0, record = _LOGGER.makeRecord(_LOGGER.name, logging.WARNING, __file__, 0,
@@ -50,7 +98,15 @@ class QueueHandler(logging.Handler):
except Exception: except Exception:
pass pass
def emit(self, record): def emit(self, record: logging.LogRecord) -> None:
"""Handle each log record that is emitted.
Call :func:`_put_record` to enqueue the emitted log record.
Also check if we have previously lost any log record, and if so, log a ``WARNING`` message.
:param record: the record that was emitted.
"""
try: try:
self._put_record(record) self._put_record(record)
self._try_to_report_lost_records() self._try_to_report_lost_records()
@@ -58,21 +114,60 @@ class QueueHandler(logging.Handler):
self._records_lost += 1 self._records_lost += 1
@property @property
def records_lost(self): def records_lost(self) -> int:
"""Number of log messages that have been lost while the queue was full."""
return self._records_lost return self._records_lost
class ProxyHandler(logging.Handler): class ProxyHandler(logging.Handler):
"""Handle log records in place of pending log handlers.
def __init__(self, patroni_logger): .. note::
logging.Handler.__init__(self) This is used to handle log messages while the logger thread has not started yet, in which case the queue-based
handler is not yet started.
:ivar patroni_logger: the logger thread.
"""
def __init__(self, patroni_logger: 'PatroniLogger') -> None:
"""Create a new :class:`ProxyHandler` instance.
:param patroni_logger: the logger thread.
"""
super().__init__()
self.patroni_logger = patroni_logger self.patroni_logger = patroni_logger
def emit(self, record): def emit(self, record: logging.LogRecord) -> None:
self.patroni_logger.log_handler.handle(record) """Emit each log record that is handled.
Will push the log record down to :func:`handle` method of the currently configured log handler.
:param record: the record that was emitted.
"""
if self.patroni_logger.log_handler is not None:
self.patroni_logger.log_handler.handle(record)
class PatroniLogger(Thread): class PatroniLogger(Thread):
"""Logging thread for the Patroni daemon process.
It is a 2-step logging approach. Any time a log message is issued it is initially enqueued in-memory, and then
asynchronously flushed to the final destination by the logging thread.
.. seealso::
:class:`QueueHandler`: object used for enqueueing messages in-memory.
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
:cvar NORMAL_LOG_QUEUE_SIZE: expected number of log messages per HA loop when operating under a normal situation.
:cvar DEFAULT_MAX_QUEUE_SIZE: default maximum queue size for holding a backlog of log messages that are pending
to be flushed.
:cvar LOGGING_BROKEN_EXIT_CODE: exit code to be used if it detects(``5``).
:ivar log_handler: log handler that is currently being used by the thread.
:ivar log_handler_lock: lock used to modify ``log_handler``.
"""
DEFAULT_LEVEL = 'INFO' DEFAULT_LEVEL = 'INFO'
DEFAULT_TRACEBACK_LEVEL = 'ERROR' DEFAULT_TRACEBACK_LEVEL = 'ERROR'
@@ -82,14 +177,24 @@ class PatroniLogger(Thread):
DEFAULT_MAX_QUEUE_SIZE = 1000 DEFAULT_MAX_QUEUE_SIZE = 1000
LOGGING_BROKEN_EXIT_CODE = 5 LOGGING_BROKEN_EXIT_CODE = 5
def __init__(self): def __init__(self) -> None:
"""Prepare logging queue and proxy handlers as they become ready during daemon startup.
.. note::
While Patroni is starting up it keeps ``DEBUG`` log level, and writes log messages through a proxy handler.
Once the logger thread is finally started, it switches from that proxy handler to the queue based logger,
and applies the configured log settings. The switching is used to avoid that the logger thread prevents
Patroni from shutting down if any issue occurs in the meantime until the thread is properly started.
"""
super(PatroniLogger, self).__init__() super(PatroniLogger, self).__init__()
self._queue_handler = QueueHandler() self._queue_handler = QueueHandler()
self._root_logger = logging.getLogger() self._root_logger = logging.getLogger()
self._config = None self._config: Optional[Dict[str, Any]] = None
self.log_handler = None self.log_handler = None
self.log_handler_lock = Lock() self.log_handler_lock = Lock()
self._old_handlers = [] self._old_handlers: List[logging.Handler] = []
# initially set log level to ``DEBUG`` while the logger thread has not started running yet. The daemon process
# will later adjust all log related settings with what was provided through the user configuration file.
self.reload_config({'level': 'DEBUG'}) self.reload_config({'level': 'DEBUG'})
# We will switch to the QueueHandler only when thread was started. # We will switch to the QueueHandler only when thread was started.
# This is necessary to protect from the cases when Patroni constructor # This is necessary to protect from the cases when Patroni constructor
@@ -97,26 +202,46 @@ class PatroniLogger(Thread):
self._proxy_handler = ProxyHandler(self) self._proxy_handler = ProxyHandler(self)
self._root_logger.addHandler(self._proxy_handler) self._root_logger.addHandler(self._proxy_handler)
def update_loggers(self): def update_loggers(self) -> None:
loggers = deepcopy(self._config.get('loggers') or {}) """Configure loggers' log level as defined in ``log.loggers` section of Patroni configuration.
.. note::
It creates logger objects that are not defined yet in the log manager.
"""
loggers = deepcopy((self._config or {}).get('loggers') or {})
for name, logger in self._root_logger.manager.loggerDict.items(): for name, logger in self._root_logger.manager.loggerDict.items():
# ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only
# in the ones that were defined
if not isinstance(logger, logging.PlaceHolder): if not isinstance(logger, logging.PlaceHolder):
# if this logger is present in ``log.loggers`` Patroni configuration, use the configured level,
# otherwise use ``logging.NOTSET``, which means it will inherit the level from any parent node up to
# the root for which log level is defined.
level = loggers.pop(name, logging.NOTSET) level = loggers.pop(name, logging.NOTSET)
logger.setLevel(level) logger.setLevel(level)
# define loggers that do not exist yet and set level as configured in ``log.loggers`` section of configuration.
for name, level in loggers.items(): for name, level in loggers.items():
logger = self._root_logger.manager.getLogger(name) logger = self._root_logger.manager.getLogger(name)
logger.setLevel(level) logger.setLevel(level)
def reload_config(self, config): def reload_config(self, config: Dict[str, Any]) -> None:
"""Apply log related configuration.
.. note::
It is also able to deal with runtime configuration changes.
:param config: ``log`` section from Patroni configuration.
"""
if self._config is None or not deep_compare(self._config, config): if self._config is None or not deep_compare(self._config, config):
with self._queue_handler.queue.mutex: with self._queue_handler.queue.mutex:
self._queue_handler.queue.maxsize = config.get('max_queue_size', self.DEFAULT_MAX_QUEUE_SIZE) self._queue_handler.queue.maxsize = config.get('max_queue_size', self.DEFAULT_MAX_QUEUE_SIZE)
self._root_logger.setLevel(config.get('level', PatroniLogger.DEFAULT_LEVEL)) self._root_logger.setLevel(config.get('level', PatroniLogger.DEFAULT_LEVEL))
if config.get('traceback_level', PatroniLogger.DEFAULT_TRACEBACK_LEVEL).lower() == 'debug': if config.get('traceback_level', PatroniLogger.DEFAULT_TRACEBACK_LEVEL).lower() == 'debug':
# show stack traces only if ``log.traceback_level`` is ``DEBUG``
logging.Logger.exception = debug_exception logging.Logger.exception = debug_exception
else: else:
# show stack traces as ``ERROR`` log messages
logging.Logger.exception = error_exception logging.Logger.exception = error_exception
new_handler = None new_handler = None
@@ -124,7 +249,9 @@ class PatroniLogger(Thread):
if not isinstance(self.log_handler, RotatingFileHandler): if not isinstance(self.log_handler, RotatingFileHandler):
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__)) new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler = new_handler or self.log_handler handler = new_handler or self.log_handler
handler.maxBytes = int(config.get('file_size', 25000000)) if TYPE_CHECKING: # pragma: no cover
assert isinstance(handler, RotatingFileHandler)
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
handler.backupCount = int(config.get('file_num', 4)) handler.backupCount = int(config.get('file_num', 4))
else: else:
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler): if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
@@ -137,7 +264,7 @@ class PatroniLogger(Thread):
olddateformat = (self._config or {}).get('dateformat') or None olddateformat = (self._config or {}).get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None` dateformat = config.get('dateformat') or None # Convert empty string to `None`
if oldlogformat != logformat or olddateformat != dateformat or new_handler: if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
handler.setFormatter(logging.Formatter(logformat, dateformat)) handler.setFormatter(logging.Formatter(logformat, dateformat))
if new_handler: if new_handler:
@@ -149,7 +276,13 @@ class PatroniLogger(Thread):
self._config = config.copy() self._config = config.copy()
self.update_loggers() self.update_loggers()
def _close_old_handlers(self): def _close_old_handlers(self) -> None:
"""Close old log handlers.
.. note::
It is used to remove different handlers that were configured previous to a reload in the configuration,
e.g. if we are switching from :class:`RotatingFileHandler` to class:`StreamHandler` and vice-versa.
"""
while True: while True:
with self.log_handler_lock: with self.log_handler_lock:
if not self._old_handlers: if not self._old_handlers:
@@ -160,7 +293,11 @@ class PatroniLogger(Thread):
except Exception: except Exception:
_LOGGER.exception('Failed to close the old log handler %s', handler) _LOGGER.exception('Failed to close the old log handler %s', handler)
def run(self): def run(self) -> None:
"""Run logger's thread main loop.
Keep consuming log queue until requested to quit through ``None`` special log record.
"""
# switch to QueueHandler only when the thread was started # switch to QueueHandler only when the thread was started
with self.log_handler_lock: with self.log_handler_lock:
self._root_logger.addHandler(self._queue_handler) self._root_logger.addHandler(self._queue_handler)
@@ -170,12 +307,17 @@ class PatroniLogger(Thread):
while True: while True:
self._close_old_handlers() self._close_old_handlers()
if TYPE_CHECKING: # pragma: no cover
assert self.log_handler is not None
record = self._queue_handler.queue.get(True) record = self._queue_handler.queue.get(True)
# special message that indicates Patroni is shutting down
if record is None: if record is None:
break break
if self._root_logger.level == logging.INFO: if self._root_logger.level == logging.INFO:
# messages like ``Lock owner: postgresql0; I am postgresql1`` will be shown only when stream doesn't
# look normal. This is used to reduce chattiness of Patroni logs.
if record.msg.startswith('Lock owner: '): if record.msg.startswith('Lock owner: '):
prev_record, record = record, None prev_record, record = record, None
else: else:
@@ -189,8 +331,10 @@ class PatroniLogger(Thread):
self._queue_handler.queue.task_done() self._queue_handler.queue.task_done()
def shutdown(self): def shutdown(self) -> None:
"""Shut down the logger thread."""
try: try:
# ``None`` is a special message indicating to queue handler that it should quit its main loop.
self._queue_handler.queue.put_nowait(None) self._queue_handler.queue.put_nowait(None)
except Full: # Queue is full. except Full: # Queue is full.
# It seems that logging is not working, exiting with non-standard exit-code is the best we can do. # It seems that logging is not working, exiting with non-standard exit-code is the best we can do.
@@ -199,9 +343,11 @@ class PatroniLogger(Thread):
logging.shutdown() logging.shutdown()
@property @property
def queue_size(self): def queue_size(self) -> int:
"""Number of log records in the queue."""
return self._queue_handler.queue.qsize() return self._queue_handler.queue.qsize()
@property @property
def records_lost(self): def records_lost(self) -> int:
"""Number of logging records that have been lost while the queue was full."""
return self._queue_handler.records_lost return self._queue_handler.records_lost
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+81 -27
View File
@@ -4,41 +4,96 @@ import shlex
import tempfile import tempfile
import time import time
from ..dcs import RemoteMember from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..async_executor import CriticalTask
from ..dcs import Leader, Member, RemoteMember
from ..psycopg import quote_ident, quote_literal from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare from ..utils import deep_compare, unquote
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Bootstrap(object): class Bootstrap(object):
def __init__(self, postgresql): def __init__(self, postgresql: 'Postgresql') -> None:
self._postgresql = postgresql self._postgresql = postgresql
self._running_custom_bootstrap = False self._running_custom_bootstrap = False
@property @property
def running_custom_bootstrap(self): def running_custom_bootstrap(self) -> bool:
return self._running_custom_bootstrap return self._running_custom_bootstrap
@property @property
def keep_existing_recovery_conf(self): def keep_existing_recovery_conf(self) -> bool:
return self._running_custom_bootstrap and self._keep_existing_recovery_conf return self._running_custom_bootstrap and self._keep_existing_recovery_conf
@staticmethod @staticmethod
def process_user_options(tool, options, not_allowed_options, error_handler): def process_user_options(tool: str,
user_options = [] options: Union[Any, Dict[str, str], List[Union[str, Dict[str, Any]]]],
not_allowed_options: Tuple[str, ...],
error_handler: Callable[[str], None]) -> List[str]:
"""Format *options* in a list or dictionary format into command line long form arguments.
def option_is_allowed(name): .. note::
The format of the output of this method is to prepare arguments for use in the ``initdb``
method of `self._postgres`.
:Example:
The *options* can be defined as a dictionary of key, values to be converted into arguments:
>>> Bootstrap.process_user_options('foo', {'foo': 'bar'}, (), print)
['--foo=bar']
Or as a list of single string arguments
>>> Bootstrap.process_user_options('foo', ['yes'], (), print)
['--yes']
Or as a list of key, value options
>>> Bootstrap.process_user_options('foo', [{'foo': 'bar'}], (), print)
['--foo=bar']
Or a combination of single and key, values
>>> Bootstrap.process_user_options('foo', ['yes', {'foo': 'bar'}], (), print)
['--yes', '--foo=bar']
Options that contain spaces are passed as is to ``subprocess.call``
>>> Bootstrap.process_user_options('foo', [{'foo': 'bar baz'}], (), print)
['--foo=bar baz']
Options that are quoted will be unquoted, so the quotes aren't interpreted
literally by the postgres command
>>> Bootstrap.process_user_options('foo', [{'foo': '"bar baz"'}], (), print)
['--foo=bar baz']
.. note::
The *error_handler* is called when any of these conditions are met:
* Key, value dictionaries in the list form contains multiple keys.
* If a key is listed in *not_allowed_options*.
* If the options list is not in the required structure.
:param tool: The name of the tool used in error reports to *error_handler*
:param options: Options to parse as a list of key, values or single values, or a dictionary
:param not_allowed_options: List of keys that cannot be used in the list of key, value formatted options
:param error_handler: A function which will be called when an error condition is encountered
:returns: List of long form arguments to pass to the named tool
"""
user_options: List[str] = []
def option_is_allowed(name: str) -> bool:
ret = name not in not_allowed_options ret = name not in not_allowed_options
if not ret: if not ret:
error_handler('{0} option for {1} is not allowed'.format(name, tool)) error_handler('{0} option for {1} is not allowed'.format(name, tool))
return ret return ret
if isinstance(options, dict): if isinstance(options, dict):
for k, v in options.items(): for key, val in options.items():
if k and v: if key and val:
user_options.append('--{0}={1}'.format(k, v)) user_options.append('--{0}={1}'.format(key, unquote(val)))
elif isinstance(options, list): elif isinstance(options, list):
for opt in options: for opt in options:
if isinstance(opt, str) and option_is_allowed(opt): if isinstance(opt, str) and option_is_allowed(opt):
@@ -48,7 +103,7 @@ class Bootstrap(object):
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]): if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]):
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed' error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
' and value should be a string'.format(tool, opt[keys[0]])) ' and value should be a string'.format(tool, opt[keys[0]]))
user_options.append('--{0}={1}'.format(keys[0], opt[keys[0]])) user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else: else:
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))
@@ -56,11 +111,11 @@ class Bootstrap(object):
error_handler('{0} options must be list or 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: Any) -> bool:
self._postgresql.set_state('initializing new cluster') self._postgresql.set_state('initializing new cluster')
not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version') not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version')
def error_handler(e): def error_handler(e: str) -> None:
raise Exception(e) raise Exception(e)
options = self.process_user_options('initdb', config or [], not_allowed_options, error_handler) options = self.process_user_options('initdb', config or [], not_allowed_options, error_handler)
@@ -74,9 +129,8 @@ class Bootstrap(object):
os.write(fd, self._postgresql.config.superuser['password'].encode('utf-8')) os.write(fd, self._postgresql.config.superuser['password'].encode('utf-8'))
os.close(fd) os.close(fd)
options.append('--pwfile={0}'.format(pwfile)) options.append('--pwfile={0}'.format(pwfile))
options = ['-o', ' '.join(options)] if options else []
ret = self._postgresql.pg_ctl('initdb', *options) ret = self._postgresql.initdb(*options)
if pwfile: if pwfile:
os.remove(pwfile) os.remove(pwfile)
if ret: if ret:
@@ -85,7 +139,7 @@ class Bootstrap(object):
self._postgresql.set_state('initdb failed') self._postgresql.set_state('initdb failed')
return ret return ret
def _post_restore(self): def _post_restore(self) -> None:
self._postgresql.config.restore_configuration_files() self._postgresql.config.restore_configuration_files()
self._postgresql.configure_server_parameters() self._postgresql.configure_server_parameters()
@@ -96,7 +150,7 @@ class Bootstrap(object):
if os.path.exists(trigger_file): if os.path.exists(trigger_file):
os.unlink(trigger_file) os.unlink(trigger_file)
def _custom_bootstrap(self, config): def _custom_bootstrap(self, config: Any) -> bool:
self._postgresql.set_state('running custom bootstrap script') self._postgresql.set_state('running custom bootstrap script')
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope, params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
'--datadir=' + self._postgresql.data_dir] '--datadir=' + self._postgresql.data_dir]
@@ -116,7 +170,7 @@ class Bootstrap(object):
self._postgresql.config.remove_recovery_conf() self._postgresql.config.remove_recovery_conf()
return True return True
def call_post_bootstrap(self, config): def call_post_bootstrap(self, config: Dict[str, Any]) -> bool:
""" """
runs a script after initdb or custom bootstrap script is called and waits until completion. runs a script after initdb or custom bootstrap script is called and waits until completion.
""" """
@@ -131,7 +185,7 @@ class Bootstrap(object):
r['host'] = 'localhost' # set it to localhost to write into pgpass r['host'] = 'localhost' # set it to localhost to write into pgpass
env = self._postgresql.config.write_pgpass(r) env = self._postgresql.config.write_pgpass(r)
env['PGOPTIONS'] = '-c synchronous_commit=local' env['PGOPTIONS'] = '-c synchronous_commit=local -c statement_timeout=0'
try: try:
ret = self._postgresql.cancellable.call(shlex.split(cmd) + [connstring], env=env) ret = self._postgresql.cancellable.call(shlex.split(cmd) + [connstring], env=env)
@@ -143,7 +197,7 @@ class Bootstrap(object):
return False return False
return True return True
def create_replica(self, clone_member): def create_replica(self, clone_member: Union[Leader, Member, None]) -> Optional[int]:
""" """
create the replica according to the replica_method create the replica according to the replica_method
defined by the user. this is a list, so we need to defined by the user. this is a list, so we need to
@@ -230,7 +284,7 @@ class Bootstrap(object):
self._postgresql.set_state('stopped') self._postgresql.set_state('stopped')
return ret return ret
def basebackup(self, conn_url, env, options): def basebackup(self, conn_url: str, env: Dict[str, str], options: Dict[str, Any]) -> Optional[int]:
# creates a replica data dir using pg_basebackup. # creates a replica data dir using pg_basebackup.
# this is the default, built-in create_replica_methods # this is the default, built-in create_replica_methods
# tries twice, then returns failure (as 1) # tries twice, then returns failure (as 1)
@@ -265,7 +319,7 @@ class Bootstrap(object):
return ret return ret
def clone(self, clone_member): def clone(self, clone_member: Union[Leader, Member, None]) -> bool:
""" """
- initialize the replica from an existing member (primary or replica) - initialize the replica from an existing member (primary or replica)
- initialize the replica using the replica creation method that - initialize the replica using the replica creation method that
@@ -278,7 +332,7 @@ class Bootstrap(object):
self._post_restore() self._post_restore()
return ret return ret
def bootstrap(self, config): def bootstrap(self, config: Dict[str, Any]) -> bool:
""" Initialize a new node from scratch and start it. """ """ Initialize a new node from scratch and start it. """
pg_hba = config.get('pg_hba', []) pg_hba = config.get('pg_hba', [])
method = config.get('method') or 'initdb' method = config.get('method') or 'initdb'
@@ -290,9 +344,9 @@ class Bootstrap(object):
method = 'initdb' method = 'initdb'
do_initialize = self._initdb do_initialize = self._initdb
return do_initialize(config.get(method)) and self._postgresql.config.append_pg_hba(pg_hba) \ return do_initialize(config.get(method)) and self._postgresql.config.append_pg_hba(pg_hba) \
and self._postgresql.config.save_configuration_files() and self._postgresql.start() and self._postgresql.config.save_configuration_files() and bool(self._postgresql.start())
def create_or_update_role(self, name, password, options): def create_or_update_role(self, name: str, password: Optional[str], options: List[str]) -> None:
options = list(map(str.upper, options)) options = list(map(str.upper, options))
if 'NOLOGIN' not in options and 'LOGIN' not in options: if 'NOLOGIN' not in options and 'LOGIN' not in options:
options.append('LOGIN') options.append('LOGIN')
@@ -322,7 +376,7 @@ END;$$""".format(quote_literal(name), quote_ident(name, self._postgresql.connect
self._postgresql.query('RESET log_statement') self._postgresql.query('RESET log_statement')
self._postgresql.query('RESET pg_stat_statements.track_utility') self._postgresql.query('RESET pg_stat_statements.track_utility')
def post_bootstrap(self, config, task): def post_bootstrap(self, config: Dict[str, Any], task: CriticalTask) -> Optional[bool]:
try: try:
postgresql = self._postgresql postgresql = self._postgresql
superuser = postgresql.config.superuser superuser = postgresql.config.superuser
+9 -7
View File
@@ -17,7 +17,7 @@ class CallbackAction(str, Enum):
ON_RELOAD = "on_reload" ON_RELOAD = "on_reload"
ON_ROLE_CHANGE = "on_role_change" ON_ROLE_CHANGE = "on_role_change"
def __repr__(self): def __repr__(self) -> str:
return self.value return self.value
@@ -60,15 +60,17 @@ class CallbackExecutor(CancellableExecutor, Thread):
self._cmd = cmd self._cmd = cmd
self._condition.notify() self._condition.notify()
def run(self): def run(self) -> None:
while True: while True:
with self._condition: with self._condition:
if self._cmd is None: if self._cmd is None:
self._condition.wait() self._condition.wait()
cmd, self._cmd = self._cmd, None cmd, self._cmd = self._cmd, None
with self._lock: if cmd is not None:
if not self._start_process(cmd, close_fds=True): with self._lock:
continue if not self._start_process(cmd, close_fds=True):
self._process.wait() continue
self._kill_children() if self._process:
self._process.wait()
self._kill_children()
+16 -14
View File
@@ -5,6 +5,7 @@ import subprocess
from patroni.exceptions import PostgresException from patroni.exceptions import PostgresException
from patroni.utils import polling_loop from patroni.utils import polling_loop
from threading import Lock from threading import Lock
from typing import Any, Dict, List, Optional, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,13 +16,13 @@ class CancellableExecutor(object):
There must be only one such process so that AsyncExecutor can easily cancel it. There must be only one such process so that AsyncExecutor can easily cancel it.
""" """
def __init__(self): def __init__(self) -> None:
self._process = None self._process = None
self._process_cmd = None self._process_cmd = None
self._process_children = [] self._process_children: List[psutil.Process] = []
self._lock = Lock() self._lock = Lock()
def _start_process(self, cmd, *args, **kwargs): def _start_process(self, cmd: List[str], *args: Any, **kwargs: Any) -> Optional[bool]:
"""This method must be executed only when the `_lock` is acquired""" """This method must be executed only when the `_lock` is acquired"""
try: try:
@@ -29,10 +30,10 @@ class CancellableExecutor(object):
self._process_cmd = cmd self._process_cmd = cmd
self._process = psutil.Popen(cmd, *args, **kwargs) self._process = psutil.Popen(cmd, *args, **kwargs)
except Exception: except Exception:
return logger.exception('Failed to execute %s', cmd) return logger.exception('Failed to execute %s', cmd)
return True return True
def _kill_process(self): def _kill_process(self) -> None:
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:
@@ -53,8 +54,8 @@ class CancellableExecutor(object):
except psutil.AccessDenied as e: except psutil.AccessDenied as e:
logger.warning('Failed to kill the process: %s', e.msg) logger.warning('Failed to kill the process: %s', e.msg)
def _kill_children(self): def _kill_children(self) -> None:
waitlist = [] waitlist: List[psutil.Process] = []
with self._lock: with self._lock:
for child in self._process_children: for child in self._process_children:
try: try:
@@ -69,15 +70,16 @@ class CancellableExecutor(object):
class CancellableSubprocess(CancellableExecutor): class CancellableSubprocess(CancellableExecutor):
def __init__(self): def __init__(self) -> None:
super(CancellableSubprocess, self).__init__() super(CancellableSubprocess, self).__init__()
self._is_cancelled = False self._is_cancelled = False
def call(self, *args, **kwargs): def call(self, *args: Any, **kwargs: Union[Any, Dict[str, str]]) -> Optional[int]:
for s in ('stdin', 'stdout', 'stderr'): for s in ('stdin', 'stdout', 'stderr'):
kwargs.pop(s, None) kwargs.pop(s, None)
communicate = kwargs.pop('communicate', None) communicate: Optional[Dict[str, str]] = kwargs.pop('communicate', None)
input_data = None
if isinstance(communicate, dict): if isinstance(communicate, dict):
input_data = communicate.get('input') input_data = communicate.get('input')
if input_data: if input_data:
@@ -96,7 +98,7 @@ class CancellableSubprocess(CancellableExecutor):
self._is_cancelled = False self._is_cancelled = False
started = self._start_process(*args, **kwargs) started = self._start_process(*args, **kwargs)
if started: if started and self._process is not None:
if isinstance(communicate, dict): if isinstance(communicate, dict):
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data) communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data)
return self._process.wait() return self._process.wait()
@@ -105,16 +107,16 @@ class CancellableSubprocess(CancellableExecutor):
self._process = None self._process = None
self._kill_children() self._kill_children()
def reset_is_cancelled(self): def reset_is_cancelled(self) -> None:
with self._lock: with self._lock:
self._is_cancelled = False self._is_cancelled = False
@property @property
def is_cancelled(self): def is_cancelled(self) -> bool:
with self._lock: with self._lock:
return self._is_cancelled return self._is_cancelled
def cancel(self, kill=False): def cancel(self, kill: bool = False) -> None:
with self._lock: with self._lock:
self._is_cancelled = True self._is_cancelled = True
if self._process is None or not self._process.is_running(): if self._process is None or not self._process.is_running():
+101 -71
View File
@@ -4,11 +4,17 @@ import time
from threading import Condition, Event, Thread from threading import Condition, Event, Thread
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from .connection import Connection from .connection import Connection
from ..dcs import CITUS_COORDINATOR_GROUP_ID from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
from ..psycopg import connect, quote_ident from ..psycopg import connect, quote_ident
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
from psycopg2 import cursor
from . import Postgresql
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$') CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,7 +22,8 @@ logger = logging.getLogger(__name__)
class PgDistNode(object): class PgDistNode(object):
"""Represents a single row in the `pg_dist_node` table""" """Represents a single row in the `pg_dist_node` table"""
def __init__(self, group, host, port, event, nodeid=None, timeout=None, cooldown=None): def __init__(self, group: int, host: str, port: int, event: str, nodeid: Optional[int] = None,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
self.group = group self.group = group
# A weird way of pausing client connections by adding the `-demoted` suffix to the hostname # A weird way of pausing client connections by adding the `-demoted` suffix to the hostname
self.host = host + ('-demoted' if event == 'before_demote' else '') self.host = host + ('-demoted' if event == 'before_demote' else '')
@@ -29,7 +36,7 @@ class PgDistNode(object):
# If transaction was started, we need to COMMIT/ROLLBACK before the deadline # If transaction was started, we need to COMMIT/ROLLBACK before the deadline
self.timeout = timeout self.timeout = timeout
self.cooldown = cooldown or 10000 # 10s by default self.cooldown = cooldown or 10000 # 10s by default
self.deadline = 0 self.deadline: float = 0
# All changes in the pg_dist_node are serialized on the Patroni # All changes in the pg_dist_node are serialized on the Patroni
# side by performing them from a thread. The thread, that is # side by performing them from a thread. The thread, that is
@@ -38,83 +45,85 @@ class PgDistNode(object):
# the worker, and once it is done notify the calling thread. # the worker, and once it is done notify the calling thread.
self._event = Event() self._event = Event()
def wait(self): def wait(self) -> None:
self._event.wait() self._event.wait()
def wakeup(self): def wakeup(self) -> None:
self._event.set() self._event.set()
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return isinstance(other, PgDistNode) and self.event == other.event\ return isinstance(other, PgDistNode) and self.event == other.event\
and self.host == other.host and self.port == other.port and self.host == other.host and self.port == other.port
def __ne__(self, other): def __ne__(self, other: Any) -> bool:
return not self == other return not self == other
def __str__(self): def __str__(self) -> str:
return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})' return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})'
.format(self.nodeid, self.group, self.host, self.port, self.event)) .format(self.nodeid, self.group, self.host, self.port, self.event))
def __repr__(self): def __repr__(self) -> str:
return str(self) return str(self)
class CitusHandler(Thread): class CitusHandler(Thread):
def __init__(self, postgresql, config): def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
super(CitusHandler, self).__init__() super(CitusHandler, self).__init__()
self.daemon = True self.daemon = True
self._postgresql = postgresql self._postgresql = postgresql
self._config = config self._config = config
self._connection = Connection() self._connection = Connection()
self._pg_dist_node = {} # Cache of pg_dist_node: {groupid: PgDistNode()} self._pg_dist_node: Dict[int, PgDistNode] = {} # Cache of pg_dist_node: {groupid: PgDistNode()}
self._tasks = [] # Requests to change pg_dist_node, every task is a `PgDistNode` self._tasks: List[PgDistNode] = [] # Requests to change pg_dist_node, every task is a `PgDistNode`
self._condition = Condition() # protects _pg_dist_node, _tasks, and _schedule_load_pg_dist_node self._in_flight: Optional[PgDistNode] = None # Reference to the `PgDistNode` being changed in a transaction
self._in_flight = None # Reference to the `PgDistNode` if there is a transaction in progress changing it self._schedule_load_pg_dist_node = True # Flag that "pg_dist_node" should be queried from the database
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
self.schedule_cache_rebuild() self.schedule_cache_rebuild()
def is_enabled(self): def is_enabled(self) -> bool:
return isinstance(self._config, dict) return isinstance(self._config, dict)
def group(self): def group(self) -> Optional[int]:
return self._config['group'] return int(self._config['group']) if isinstance(self._config, dict) else None
def is_coordinator(self): def is_coordinator(self) -> bool:
return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID
def is_worker(self): def is_worker(self) -> bool:
return self.is_enabled() and not self.is_coordinator() return self.is_enabled() and not self.is_coordinator()
def set_conn_kwargs(self, kwargs): def set_conn_kwargs(self, kwargs: Dict[str, Any]) -> None:
if self.is_enabled(): if isinstance(self._config, dict): # self.is_enabled():
kwargs.update({'dbname': self._config['database'], kwargs.update({'dbname': self._config['database'],
'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'}) 'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
self._connection.set_conn_kwargs(kwargs) self._connection.set_conn_kwargs(kwargs)
def schedule_cache_rebuild(self): def schedule_cache_rebuild(self) -> None:
with self._condition: with self._condition:
self._schedule_load_pg_dist_node = True self._schedule_load_pg_dist_node = True
def on_demote(self): def on_demote(self) -> None:
with self._condition: with self._condition:
self._pg_dist_node.clear() self._pg_dist_node.clear()
self._tasks[:] = [] self._tasks[:] = []
self._in_flight = None self._in_flight = None
def query(self, sql, *params): def query(self, sql: str, *params: Any) -> Union['Cursor[Any]', 'cursor']:
try: try:
logger.debug('query(%s, %s)', sql, params) logger.debug('query(%s, %s)', sql, params)
cursor = self._connection.cursor() cursor = self._connection.cursor()
cursor.execute(sql, params or None) cursor.execute(sql.encode('utf-8'), params or None)
return cursor return cursor
except Exception as e: except Exception as e:
logger.error('Exception when executing query "%s", (%s): %r', sql, params, e) logger.error('Exception when executing query "%s", (%s): %r', sql, params, e)
self._connection.close() self._connection.close()
self._in_flight = None with self._condition:
self._in_flight = None
self.schedule_cache_rebuild() self.schedule_cache_rebuild()
raise e raise e
def load_pg_dist_node(self): def load_pg_dist_node(self) -> bool:
"""Read from the `pg_dist_node` table and put it into the local cache""" """Read from the `pg_dist_node` table and put it into the local cache"""
with self._condition: with self._condition:
@@ -132,7 +141,7 @@ class CitusHandler(Thread):
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in cursor} self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in cursor}
return True return True
def sync_pg_dist_node(self, cluster): def sync_pg_dist_node(self, cluster: Cluster) -> None:
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop. """Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop.
We can't always rely on REST API calls from worker nodes in order We can't always rely on REST API calls from worker nodes in order
@@ -156,12 +165,12 @@ class CitusHandler(Thread):
and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running': and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running':
self.add_task('after_promote', group, leader.conn_url) self.add_task('after_promote', group, leader.conn_url)
def find_task_by_group(self, group): def find_task_by_group(self, group: int) -> Optional[int]:
for i, task in enumerate(self._tasks): for i, task in enumerate(self._tasks):
if task.group == group: if task.group == group:
return i return i
def pick_task(self): def pick_task(self) -> Tuple[Optional[int], Optional[PgDistNode]]:
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list """Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
Tasks are picked by following priorities: Tasks are picked by following priorities:
@@ -195,28 +204,33 @@ class CitusHandler(Thread):
task.nodeid = self._pg_dist_node[task.group].nodeid task.nodeid = self._pg_dist_node[task.group].nodeid
return i, task return i, task
def update_node(self, task): def update_node(self, task: PgDistNode) -> None:
if task.nodeid is not None: if task.nodeid is not None:
self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)', self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)',
task.nodeid, task.host, task.port, task.cooldown) task.nodeid, task.host, task.port, task.cooldown)
elif task.event != 'before_demote': elif task.event != 'before_demote':
task.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')", row = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')",
task.host, task.port, task.group).fetchone()[0] task.host, task.port, task.group).fetchone()
if row is not None:
task.nodeid = row[0]
def process_task(self, task): def process_task(self, task: PgDistNode) -> bool:
"""Updates a single row in `pg_dist_node` table, optionally in a transaction. """Updates a single row in `pg_dist_node` table, optionally in a transaction.
The transaction is started if we do a demote of the worker node The transaction is started if we do a demote of the worker node or before promoting the other worker if
or before promoting the other worker if there is not transaction there is no transaction in progress. And, the transaction is committed when the switchover/failover completed.
in progress. And, the transaction it is committed when the
switchover/failover completed.
This method returns `True` if node was updated (optionally, .. note:
transaction was committed) as an indicator that The maximum lifetime of the transaction in progress is controlled outside of this method.
the `self._pg_dist_node` cache should be updated.
The maximum lifetime of the transaction in progress .. note:
is controlled outside of this method.""" Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread.
:param task: reference to a :class:`PgDistNode` object that represents a row to be updated/created.
:returns: `True` if the row was succesfully created/updated or transaction in progress
was committed as an indicator that the `self._pg_dist_node` cache should be updated,
or, if the new transaction was opened, this method returns `False`.
"""
if task.event == 'after_promote': if task.event == 'after_promote':
# The after_promote may happen without previous before_demote and/or # The after_promote may happen without previous before_demote and/or
@@ -227,7 +241,6 @@ class CitusHandler(Thread):
self.update_node(task) self.update_node(task)
if self._in_flight: if self._in_flight:
self.query('COMMIT') self.query('COMMIT')
self._in_flight = None
return True return True
else: # before_demote, before_promote else: # before_demote, before_promote
if task.timeout: if task.timeout:
@@ -235,31 +248,37 @@ class CitusHandler(Thread):
if not self._in_flight: if not self._in_flight:
self.query('BEGIN') self.query('BEGIN')
self.update_node(task) self.update_node(task)
self._in_flight = task
return False return False
def process_tasks(self): def process_tasks(self) -> None:
while True: while True:
# Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread.
if not self._in_flight and not self.load_pg_dist_node(): if not self._in_flight and not self.load_pg_dist_node():
break break
i, task = self.pick_task() i, task = self.pick_task()
if not task: if not task or i is None:
break break
try: try:
update_cache = self.process_task(task) update_cache = self.process_task(task)
except Exception as e: except Exception as e:
logger.error('Exception when working with pg_dist_node: %r', e) logger.error('Exception when working with pg_dist_node: %r', e)
update_cache = False update_cache = None
with self._condition: with self._condition:
if self._tasks: if self._tasks:
if update_cache: if update_cache:
self._pg_dist_node[task.group] = task self._pg_dist_node[task.group] = task
if update_cache is False: # an indicator that process_tasks has started a transaction
self._in_flight = task
else:
self._in_flight = None
if id(self._tasks[i]) == id(task): if id(self._tasks[i]) == id(task):
self._tasks.pop(i) self._tasks.pop(i)
task.wakeup() task.wakeup()
def run(self): def run(self) -> None:
while True: while True:
try: try:
with self._condition: with self._condition:
@@ -280,15 +299,23 @@ class CitusHandler(Thread):
except Exception: except Exception:
logger.exception('run') logger.exception('run')
def _add_task(self, task): def _add_task(self, task: PgDistNode) -> bool:
with self._condition: with self._condition:
i = self.find_task_by_group(task.group) i = self.find_task_by_group(task.group)
# task.timeout is None is an indicator that it was scheduled # The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
# from the sync_pg_dist_node() and we don't want to override if task.timeout is None:
# already existing task created from REST API. # We don't want to override the already existing task created from REST API.
if task.timeout is None and (i is not None or self._in_flight and self._in_flight.group == task.group): if i is not None and self._tasks[i].timeout is not None:
return False return False
# There is a little race condition with tasks created from REST API - the call made "before" the member
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
# task based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_pg_dist_node()
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time():
return False
# Override already existing task for the same worker group # Override already existing task for the same worker group
if i is not None: if i is not None:
@@ -306,32 +333,34 @@ class CitusHandler(Thread):
return True return True
return False return False
def add_task(self, event, group, conn_url, timeout=None, cooldown=None): def add_task(self, event: str, group: int, conn_url: str,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistNode]:
try: try:
r = urlparse(conn_url) r = urlparse(conn_url)
except Exception as e: except Exception as e:
return logger.error('Failed to parse connection url %s: %r', conn_url, e) return logger.error('Failed to parse connection url %s: %r', conn_url, e)
host = r.hostname host = r.hostname
port = r.port or 5432 if host:
task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown) port = r.port or 5432
return task if self._add_task(task) else None task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown)
return task if self._add_task(task) else None
def handle_event(self, cluster, event): def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
if not self.is_alive(): if not self.is_alive():
return return
cluster = cluster.workers.get(event['group']) worker = cluster.workers.get(event['group'])
if not (cluster and cluster.leader and cluster.leader.name == event['leader'] and cluster.leader.conn_url): if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url):
return return
task = self.add_task(event['type'], event['group'], task = self.add_task(event['type'], event['group'],
cluster.leader.conn_url, worker.leader.conn_url,
event['timeout'], event['cooldown']*1000) event['timeout'], event['cooldown'] * 1000)
if task and event['type'] == 'before_demote': if task and event['type'] == 'before_demote':
task.wait() task.wait()
def bootstrap(self): def bootstrap(self) -> None:
if not self.is_enabled(): if not isinstance(self._config, dict): # self.is_enabled()
return return
conn_kwargs = self._postgresql.config.local_connect_kwargs conn_kwargs = self._postgresql.config.local_connect_kwargs
@@ -340,7 +369,8 @@ class CitusHandler(Thread):
conn = connect(**conn_kwargs) conn = connect(**conn_kwargs)
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(quote_ident(self._config['database'], conn))) cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
finally: finally:
conn.close() conn.close()
@@ -364,7 +394,7 @@ class CitusHandler(Thread):
finally: finally:
conn.close() conn.close()
def adjust_postgres_gucs(self, parameters): def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
if not self.is_enabled(): if not self.is_enabled():
return return
@@ -381,9 +411,9 @@ class CitusHandler(Thread):
# Resharding in Citus implemented using logical replication # Resharding in Citus implemented using logical replication
parameters['wal_level'] = 'logical' parameters['wal_level'] = 'logical'
def ignore_replication_slot(self, slot): def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
if self.is_enabled() and self._postgresql.is_leader() and\ if isinstance(self._config, dict) and self._postgresql.is_leader() and\
slot['type'] == 'logical' and slot['database'] == self._config['database']: slot['type'] == 'logical' and slot['database'] == self._config['database']:
m = CITUS_SLOT_NAME_RE.match(slot['name']) m = CITUS_SLOT_NAME_RE.match(slot['name'])
return m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'] return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
return False return False
+222 -154
View File
@@ -6,22 +6,29 @@ import socket
import stat import stat
import time import time
from contextlib import contextmanager
from urllib.parse import urlparse, parse_qsl, unquote from urllib.parse import urlparse, parse_qsl, unquote
from types import TracebackType
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from .validator import CaseInsensitiveDict, recovery_parameters,\ from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
transform_postgresql_parameter_value, transform_recovery_parameter_value from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import slot_name_from_member_name, RemoteMember from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException from ..exceptions import PatroniFatalException
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, \ from ..file_perm import pg_perm
validate_directory, is_subpath from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
from ..validator import IntValidator, EnumValidator
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PARAMETER_RE = re.compile(r'([a-z_]+)\s*=\s*') PARAMETER_RE = re.compile(r'([a-z_]+)\s*=\s*')
def conninfo_uri_parse(dsn): def conninfo_uri_parse(dsn: str) -> Dict[str, str]:
ret = {} ret: Dict[str, str] = {}
r = urlparse(dsn) r = urlparse(dsn)
if r.username: if r.username:
ret['user'] = r.username ret['user'] = r.username
@@ -29,21 +36,19 @@ def conninfo_uri_parse(dsn):
ret['password'] = r.password ret['password'] = r.password
if r.path[1:]: if r.path[1:]:
ret['dbname'] = r.path[1:] ret['dbname'] = r.path[1:]
hosts = [] hosts: List[str] = []
ports = [] ports: List[str] = []
for netloc in r.netloc.split('@')[-1].split(','): for netloc in r.netloc.split('@')[-1].split(','):
host = port = None host = None
if '[' in netloc and ']' in netloc: if '[' in netloc and ']' in netloc:
host = netloc.split(']')[0][1:] tmp = netloc.split(']') + ['']
tmp = netloc.split(':', 1) host = tmp[0][1:]
netloc = ':'.join(tmp[:2])
tmp = netloc.rsplit(':', 1)
if host is None: if host is None:
host = tmp[0] host = tmp[0]
if len(tmp) == 2: hosts.append(host)
host, port = tmp ports.append(tmp[1] if len(tmp) == 2 else '')
if host is not None:
hosts.append(host)
if port is not None:
ports.append(port)
if hosts: if hosts:
ret['host'] = ','.join(hosts) ret['host'] = ','.join(hosts)
if ports: if ports:
@@ -56,7 +61,7 @@ def conninfo_uri_parse(dsn):
return ret return ret
def read_param_value(value): def read_param_value(value: str) -> Union[Tuple[None, None], Tuple[str, int]]:
length = len(value) length = len(value)
ret = '' ret = ''
is_quoted = value[0] == "'" is_quoted = value[0] == "'"
@@ -76,8 +81,8 @@ def read_param_value(value):
return (None, None) if is_quoted else (ret, i) return (None, None) if is_quoted else (ret, i)
def conninfo_parse(dsn): def conninfo_parse(dsn: str) -> Optional[Dict[str, str]]:
ret = {} ret: Dict[str, str] = {}
length = len(dsn) length = len(dsn)
i = 0 i = 0
while i < length: while i < length:
@@ -96,14 +101,14 @@ def conninfo_parse(dsn):
return return
value, end = read_param_value(dsn[i:]) value, end = read_param_value(dsn[i:])
if value is None: if value is None or end is None:
return return
i += end i += end
ret[param] = value ret[param] = value
return ret return ret
def parse_dsn(value): def parse_dsn(value: str) -> Optional[Dict[str, str]]:
""" """
Very simple equivalent of `psycopg2.extensions.parse_dsn` introduced in 2.7.0. Very simple equivalent of `psycopg2.extensions.parse_dsn` introduced in 2.7.0.
We are not using psycopg2 function in order to remain compatible with 2.5.4+. We are not using psycopg2 function in order to remain compatible with 2.5.4+.
@@ -111,9 +116,9 @@ def parse_dsn(value):
and sets the `sslmode`, 'gssencmode', and `channel_binding` to `prefer` if it is not present in and sets the `sslmode`, 'gssencmode', and `channel_binding` to `prefer` if it is not present in
the connection string. This is necessary to simplify comparison of the old and the new values. the connection string. This is necessary to simplify comparison of the old and the new values.
>>> r = parse_dsn('postgresql://u%2Fse:pass@:%2f123,[%2Fhost2]/db%2Fsdf?application_name=mya%2Fpp&ssl=true') >>> r = parse_dsn('postgresql://u%2Fse:pass@:%2f123,[::1]/db%2Fsdf?application_name=mya%2Fpp&ssl=true')
>>> r == {'application_name': 'mya/pp', 'host': ',/host2', 'sslmode': 'require',\ >>> r == {'application_name': 'mya/pp', 'host': ',::1', 'sslmode': 'require',\
'password': 'pass', 'port': '/123', 'user': 'u/se', 'gssencmode': 'prefer', 'channel_binding': 'prefer'} 'password': 'pass', 'port': '/123,', 'user': 'u/se', 'gssencmode': 'prefer', 'channel_binding': 'prefer'}
True True
>>> r = parse_dsn(" host = 'host' dbname = db\\\\ name requiressl=1 ") >>> r = parse_dsn(" host = 'host' dbname = db\\\\ name requiressl=1 ")
>>> r == {'host': 'host', 'sslmode': 'require', 'gssencmode': 'prefer', 'channel_binding': 'prefer'} >>> r == {'host': 'host', 'sslmode': 'require', 'gssencmode': 'prefer', 'channel_binding': 'prefer'}
@@ -147,14 +152,14 @@ def parse_dsn(value):
return ret return ret
def strip_comment(value): def strip_comment(value: str) -> str:
i = value.find('#') i = value.find('#')
if i > -1: if i > -1:
value = value[:i].strip() value = value[:i].strip()
return value return value
def read_recovery_param_value(value): def read_recovery_param_value(value: str) -> Optional[str]:
""" """
>>> read_recovery_param_value('') is None >>> read_recovery_param_value('') is None
True True
@@ -211,7 +216,7 @@ def read_recovery_param_value(value):
return value return value
def mtime(filename): def mtime(filename: str) -> Optional[float]:
try: try:
return os.stat(filename).st_mtime return os.stat(filename).st_mtime
except OSError: except OSError:
@@ -220,35 +225,49 @@ def mtime(filename):
class ConfigWriter(object): class ConfigWriter(object):
def __init__(self, filename): def __init__(self, filename: str) -> None:
self._filename = filename self._filename = filename
self._fd = None self._fd = None
def __enter__(self): def __enter__(self) -> 'ConfigWriter':
self._fd = open(self._filename, 'w') self._fd = open(self._filename, 'w')
self.writeline('# Do not edit this file manually!\n# It will be overwritten by Patroni!') self.writeline('# Do not edit this file manually!\n# It will be overwritten by Patroni!')
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
if self._fd: if self._fd:
self._fd.close() self._fd.close()
def writeline(self, line): def writeline(self, line: str) -> None:
self._fd.write(line) if self._fd:
self._fd.write('\n') self._fd.write(line)
self._fd.write('\n')
def writelines(self, lines): def writelines(self, lines: List[str]) -> None:
for line in lines: for line in lines:
self.writeline(line) self.writeline(line)
@staticmethod @staticmethod
def escape(value): # Escape (by doubling) any single quotes or backslashes in given string def escape(value: Any) -> str: # Escape (by doubling) any single quotes or backslashes in given string
return re.sub(r'([\'\\])', r'\1\1', str(value)) return re.sub(r'([\'\\])', r'\1\1', str(value))
def write_param(self, param, value): def write_param(self, param: str, value: Any) -> None:
self.writeline("{0} = '{1}'".format(param, self.escape(value))) self.writeline("{0} = '{1}'".format(param, self.escape(value)))
def _false_validator(value: Any) -> bool:
return False
def _bool_validator(value: Any) -> bool:
return parse_bool(value) is not None
def _bool_is_true_validator(value: Any) -> bool:
return parse_bool(value) is True
class ConfigHandler(object): class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options # List of parameters which must be always passed to postmaster as command line options
@@ -266,28 +285,28 @@ class ConfigHandler(object):
# check_function -- if the new value is not correct must return `!False` # check_function -- if the new value is not correct must return `!False`
# min_version -- major version of PostgreSQL when parameter was introduced # min_version -- major version of PostgreSQL when parameter was introduced
CMDLINE_OPTIONS = CaseInsensitiveDict({ CMDLINE_OPTIONS = CaseInsensitiveDict({
'listen_addresses': (None, lambda _: False, 90100), 'listen_addresses': (None, _false_validator, 90100),
'port': (None, lambda _: False, 90100), 'port': (None, _false_validator, 90100),
'cluster_name': (None, lambda _: False, 90500), 'cluster_name': (None, _false_validator, 90500),
'wal_level': ('hot_standby', lambda v: v.lower() in ('hot_standby', 'replica', 'logical'), 90100), 'wal_level': ('hot_standby', EnumValidator(('hot_standby', 'replica', 'logical')), 90100),
'hot_standby': ('on', lambda _: False, 90100), 'hot_standby': ('on', _bool_is_true_validator, 90100),
'max_connections': (100, lambda v: int(v) >= 25, 90100), 'max_connections': (100, IntValidator(min=25), 90100),
'max_wal_senders': (10, lambda v: int(v) >= 3, 90100), 'max_wal_senders': (10, IntValidator(min=3), 90100),
'wal_keep_segments': (8, lambda v: int(v) >= 1, 90100), 'wal_keep_segments': (8, IntValidator(min=1), 90100),
'wal_keep_size': ('128MB', lambda v: parse_int(v, 'MB') >= 16, 130000), 'wal_keep_size': ('128MB', IntValidator(min=16, base_unit='MB'), 130000),
'max_prepared_transactions': (0, lambda v: int(v) >= 0, 90100), 'max_prepared_transactions': (0, IntValidator(min=0), 90100),
'max_locks_per_transaction': (64, lambda v: int(v) >= 32, 90100), 'max_locks_per_transaction': (64, IntValidator(min=32), 90100),
'track_commit_timestamp': ('off', lambda v: parse_bool(v) is not None, 90500), 'track_commit_timestamp': ('off', _bool_validator, 90500),
'max_replication_slots': (10, lambda v: int(v) >= 4, 90400), 'max_replication_slots': (10, IntValidator(min=4), 90400),
'max_worker_processes': (8, lambda v: int(v) >= 2, 90400), 'max_worker_processes': (8, IntValidator(min=2), 90400),
'wal_log_hints': ('on', lambda _: False, 90400) 'wal_log_hints': ('on', _bool_is_true_validator, 90400)
}) })
_RECOVERY_PARAMETERS = set(recovery_parameters.keys()) _RECOVERY_PARAMETERS = CaseInsensitiveSet(recovery_parameters.keys())
def __init__(self, postgresql, config): def __init__(self, postgresql: 'Postgresql', config: Dict[str, Any]) -> None:
self._postgresql = postgresql self._postgresql = postgresql
self._config_dir = os.path.abspath(config.get('config_dir') or postgresql.data_dir) self._config_dir = os.path.abspath(config.get('config_dir', '') or postgresql.data_dir)
config_base_name = config.get('config_base_name', 'postgresql') config_base_name = config.get('config_base_name', 'postgresql')
self._postgresql_conf = os.path.join(self._config_dir, config_base_name + '.conf') self._postgresql_conf = os.path.join(self._config_dir, config_base_name + '.conf')
self._postgresql_conf_mtime = None self._postgresql_conf_mtime = None
@@ -309,21 +328,22 @@ class ConfigHandler(object):
self._passfile_mtime = None self._passfile_mtime = None
self._synchronous_standby_names = None self._synchronous_standby_names = None
self._postmaster_ctime = None self._postmaster_ctime = None
self._current_recovery_params = None self._current_recovery_params: Optional[CaseInsensitiveDict] = None
self._config = {} self._config = {}
self._recovery_params = {} self._recovery_params = CaseInsensitiveDict()
self._server_parameters: CaseInsensitiveDict
self.reload_config(config) self.reload_config(config)
def setup_server_parameters(self): def setup_server_parameters(self) -> None:
self._server_parameters = self.get_server_parameters(self._config) self._server_parameters = self.get_server_parameters(self._config)
self._adjust_recovery_parameters() self._adjust_recovery_parameters()
def try_to_create_dir(self, d, msg): def try_to_create_dir(self, d: str, msg: str) -> None:
d = os.path.join(self._postgresql._data_dir, d) d = os.path.join(self._postgresql.data_dir, d)
if (not is_subpath(self._postgresql._data_dir, d) or not self._postgresql.data_directory_empty()): if (not is_subpath(self._postgresql.data_dir, d) or not self._postgresql.data_directory_empty()):
validate_directory(d, msg) validate_directory(d, msg)
def check_directories(self): def check_directories(self) -> None:
if "unix_socket_directories" in self._server_parameters: if "unix_socket_directories" in self._server_parameters:
for d in self._server_parameters["unix_socket_directories"].split(","): for d in self._server_parameters["unix_socket_directories"].split(","):
self.try_to_create_dir(d.strip(), "'{}' is defined in unix_socket_directories, {}") self.try_to_create_dir(d.strip(), "'{}' is defined in unix_socket_directories, {}")
@@ -335,7 +355,11 @@ class ConfigHandler(object):
"'{}' is defined in `postgresql.pgpass`, {}") "'{}' is defined in `postgresql.pgpass`, {}")
@property @property
def _configuration_to_save(self): def config_dir(self) -> str:
return self._config_dir
@property
def _configuration_to_save(self) -> List[str]:
configuration = [os.path.basename(self._postgresql_conf)] configuration = [os.path.basename(self._postgresql_conf)]
if 'custom_conf' not in self._config: if 'custom_conf' not in self._config:
configuration.append(os.path.basename(self._postgresql_base_conf_name)) configuration.append(os.path.basename(self._postgresql_base_conf_name))
@@ -345,7 +369,31 @@ class ConfigHandler(object):
configuration.append('pg_ident.conf') configuration.append('pg_ident.conf')
return configuration return configuration
def save_configuration_files(self, check_custom_bootstrap=False): def set_file_permissions(self, filename: str) -> None:
"""Set permissions of file *filename* according to the expected permissions if it resides under PGDATA.
.. note::
Do nothing if the file is not under PGDATA.
:param filename: path to a file which permissions might need to be adjusted.
"""
if is_subpath(self._postgresql.data_dir, filename):
pg_perm.set_permissions_from_data_directory(self._postgresql.data_dir)
os.chmod(filename, pg_perm.file_create_mode)
@contextmanager
def config_writer(self, filename: str) -> Iterator[ConfigWriter]:
"""Create :class:`ConfigWriter` object and set permissions on a *filename*.
:param filename: path to a config file.
:yields: :class:`ConfigWriter` object.
"""
with ConfigWriter(filename) as writer:
yield writer
self.set_file_permissions(filename)
def save_configuration_files(self, check_custom_bootstrap: bool = False) -> bool:
""" """
copy postgresql.conf to postgresql.conf.backup to be able to retrieve 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
@@ -358,11 +406,12 @@ class ConfigHandler(object):
backup_file = os.path.join(self._postgresql.data_dir, f + '.backup') backup_file = os.path.join(self._postgresql.data_dir, f + '.backup')
if os.path.isfile(config_file): if os.path.isfile(config_file):
shutil.copy(config_file, backup_file) shutil.copy(config_file, backup_file)
self.set_file_permissions(backup_file)
except IOError: except IOError:
logger.exception('unable to create backup copies of configuration files') logger.exception('unable to create backup copies of configuration files')
return True return True
def restore_configuration_files(self): def restore_configuration_files(self) -> None:
""" restore a previously saved postgresql.conf """ """ restore a previously saved postgresql.conf """
try: try:
for f in self._configuration_to_save: for f in self._configuration_to_save:
@@ -371,13 +420,15 @@ class ConfigHandler(object):
if not os.path.isfile(config_file): if not os.path.isfile(config_file):
if os.path.isfile(backup_file): if os.path.isfile(backup_file):
shutil.copy(backup_file, config_file) shutil.copy(backup_file, config_file)
self.set_file_permissions(config_file)
# Previously we didn't backup pg_ident.conf, if file is missing just create empty # Previously we didn't backup pg_ident.conf, if file is missing just create empty
elif f == 'pg_ident.conf': elif f == 'pg_ident.conf':
open(config_file, 'w').close() open(config_file, 'w').close()
self.set_file_permissions(config_file)
except IOError: except IOError:
logger.exception('unable to restore configuration files from backup') logger.exception('unable to restore configuration files from backup')
def write_postgresql_conf(self, configuration=None): def write_postgresql_conf(self, configuration: Optional[CaseInsensitiveDict] = None) -> None:
# rename the original configuration if it is necessary # rename the original configuration if it is necessary
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)
@@ -387,11 +438,12 @@ class ConfigHandler(object):
if self._postgresql.enforce_hot_standby_feedback: if self._postgresql.enforce_hot_standby_feedback:
configuration['hot_standby_feedback'] = 'on' configuration['hot_standby_feedback'] = 'on'
with ConfigWriter(self._postgresql_conf) as f: with self.config_writer(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).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,
self._postgresql.available_gucs)
if value is not None and\ if value is not None and\
(name != 'hba_file' or not self._postgresql.bootstrap.running_custom_bootstrap): (name != 'hba_file' or not self._postgresql.bootstrap.running_custom_bootstrap):
f.write_param(name, value) f.write_param(name, value)
@@ -412,13 +464,14 @@ class ConfigHandler(object):
if not self._postgresql.bootstrap.keep_existing_recovery_conf: if not self._postgresql.bootstrap.keep_existing_recovery_conf:
self._sanitize_auto_conf() self._sanitize_auto_conf()
def append_pg_hba(self, config): def append_pg_hba(self, config: List[str]) -> bool:
if not self.hba_file and not self._config.get('pg_hba'): if not self.hba_file and not self._config.get('pg_hba'):
with open(self._pg_hba_conf, 'a') as f: with open(self._pg_hba_conf, 'a') as f:
f.write('\n{}\n'.format('\n'.join(config))) f.write('\n{}\n'.format('\n'.join(config)))
self.set_file_permissions(self._pg_hba_conf)
return True return True
def replace_pg_hba(self): def replace_pg_hba(self) -> Optional[bool]:
""" """
Replace pg_hba.conf content in the PGDATA if hba_file is not defined in the Replace pg_hba.conf content in the PGDATA if hba_file is not defined in the
`postgresql.parameters` and pg_hba is defined in `postgresql` configuration section. `postgresql.parameters` and pg_hba is defined in `postgresql` configuration section.
@@ -435,18 +488,18 @@ class ConfigHandler(object):
self.local_replication_address['host'], self.local_replication_address['port'], self.local_replication_address['host'], self.local_replication_address['port'],
0, socket.SOCK_STREAM, socket.IPPROTO_TCP)}) 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)})
with ConfigWriter(self._pg_hba_conf) as f: with self.config_writer(self._pg_hba_conf) as f:
for address, t in addresses.items(): for address, t in addresses.items():
f.writeline(( f.writeline((
'{0}\treplication\t{1}\t{3}\ttrust\n' '{0}\treplication\t{1}\t{3}\ttrust\n'
'{0}\tall\t{2}\t{3}\ttrust' '{0}\tall\t{2}\t{3}\ttrust'
).format(t, self.replication['username'], self._superuser.get('username') or 'all', address)) ).format(t, self.replication['username'], self._superuser.get('username') or 'all', address))
elif not self.hba_file and self._config.get('pg_hba'): elif not self.hba_file and self._config.get('pg_hba'):
with ConfigWriter(self._pg_hba_conf) as f: with self.config_writer(self._pg_hba_conf) as f:
f.writelines(self._config['pg_hba']) f.writelines(self._config['pg_hba'])
return True return True
def replace_pg_ident(self): def replace_pg_ident(self) -> Optional[bool]:
""" """
Replace pg_ident.conf content in the PGDATA if ident_file is not defined in the Replace pg_ident.conf content in the PGDATA if ident_file is not defined in the
`postgresql.parameters` and pg_ident is defined in the `postgresql` section. `postgresql.parameters` and pg_ident is defined in the `postgresql` section.
@@ -455,12 +508,12 @@ class ConfigHandler(object):
""" """
if not self.ident_file and self._config.get('pg_ident'): if not self.ident_file and self._config.get('pg_ident'):
with ConfigWriter(self._pg_ident_conf) as f: with self.config_writer(self._pg_ident_conf) as f:
f.writelines(self._config['pg_ident']) f.writelines(self._config['pg_ident'])
return True return True
def primary_conninfo_params(self, member): def primary_conninfo_params(self, member: Union[Leader, Member, None]) -> Optional[Dict[str, Any]]:
if not (member and member.conn_url) or member.name == self._postgresql.name: if not member or not member.conn_url or member.name == self._postgresql.name:
return None return None
ret = member.conn_kwargs(self.replication) ret = member.conn_kwargs(self.replication)
ret['application_name'] = self._postgresql.name ret['application_name'] = self._postgresql.name
@@ -475,7 +528,7 @@ class ConfigHandler(object):
del ret['dbname'] del ret['dbname']
return ret return ret
def format_dsn(self, params, include_dbname=False): def format_dsn(self, params: Dict[str, Any], include_dbname: bool = False) -> str:
# A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq # A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq
keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port', keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port',
'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl', 'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl',
@@ -491,13 +544,13 @@ class ConfigHandler(object):
else: else:
skip = {'dbname'} skip = {'dbname'}
def escape(value): def escape(value: Any) -> str:
return re.sub(r'([\'\\ ])', r'\\\1', str(value)) return re.sub(r'([\'\\ ])', r'\\\1', str(value))
return ' '.join('{0}={1}'.format(kw, escape(params[kw])) for kw in keywords return ' '.join('{0}={1}'.format(kw, escape(params[kw])) for kw in keywords
if kw not in skip and params.get(kw) is not None) if kw not in skip and params.get(kw) is not None)
def _write_recovery_params(self, fd, recovery_params): def _write_recovery_params(self, fd: ConfigWriter, recovery_params: CaseInsensitiveDict) -> None:
if self._postgresql.major_version >= 90500: if self._postgresql.major_version >= 90500:
pause_at_recovery_target = parse_bool(recovery_params.pop('pause_at_recovery_target', None)) pause_at_recovery_target = parse_bool(recovery_params.pop('pause_at_recovery_target', None))
if pause_at_recovery_target is not None: if pause_at_recovery_target is not None:
@@ -513,15 +566,16 @@ class ConfigHandler(object):
self._passfile_mtime = mtime(self._pgpass) self._passfile_mtime = mtime(self._pgpass)
value = self.format_dsn(value) value = self.format_dsn(value)
else: else:
value = transform_recovery_parameter_value(self._postgresql.major_version, name, value) value = transform_recovery_parameter_value(self._postgresql.major_version, name, value,
self._postgresql.available_gucs)
if value is None: if value is None:
continue continue
fd.write_param(name, value) fd.write_param(name, value)
def build_recovery_params(self, member): def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict:
recovery_params = CaseInsensitiveDict({p: v for p, v in self.get('recovery_conf', {}).items() recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or {}).items()
if not p.lower().startswith('recovery_target') and if not p.lower().startswith('recovery_target')
p.lower() not in ('primary_conninfo', 'primary_slot_name')}) and p.lower() not in ('primary_conninfo', 'primary_slot_name')})
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'}) recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
if self._postgresql.major_version >= 120000: if self._postgresql.major_version >= 120000:
# on pg12 we want to protect from following params being set in one of included files # on pg12 we want to protect from following params being set in one of included files
@@ -550,26 +604,26 @@ class ConfigHandler(object):
recovery_params.update({p: member.data.get(p) for p in standby_cluster_params if member and member.data.get(p)}) recovery_params.update({p: member.data.get(p) for p in standby_cluster_params if member and member.data.get(p)})
return recovery_params return recovery_params
def recovery_conf_exists(self): def recovery_conf_exists(self) -> bool:
if self._postgresql.major_version >= 120000: if self._postgresql.major_version >= 120000:
return os.path.exists(self._standby_signal) or os.path.exists(self._recovery_signal) return os.path.exists(self._standby_signal) or os.path.exists(self._recovery_signal)
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) -> str:
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
def _triggerfile_wrong_name(self): def _triggerfile_wrong_name(self) -> str:
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
def _recovery_parameters_to_compare(self): def _recovery_parameters_to_compare(self) -> CaseInsensitiveSet:
skip_params = {'pause_at_recovery_target', 'recovery_target_inclusive', skip_params = CaseInsensitiveSet({'pause_at_recovery_target', 'recovery_target_inclusive',
'recovery_target_action', 'standby_mode', self._triggerfile_wrong_name} 'recovery_target_action', 'standby_mode', self._triggerfile_wrong_name})
return self._RECOVERY_PARAMETERS - skip_params return CaseInsensitiveSet(self._RECOVERY_PARAMETERS - skip_params)
def _read_recovery_params(self): def _read_recovery_params(self) -> Tuple[Optional[CaseInsensitiveDict], Optional[bool]]:
if self._postgresql.is_starting(): if self._postgresql.is_starting():
return None, False return None, False
@@ -586,7 +640,7 @@ class ConfigHandler(object):
try: try:
values = self._get_pg_settings(self._recovery_parameters_to_compare).values() values = self._get_pg_settings(self._recovery_parameters_to_compare).values()
values = {p[0]: [p[1], p[4] == 'postmaster', p[5]] for p in values} values = CaseInsensitiveDict({p[0]: [p[1], p[4] == 'postmaster', p[5]] for p in values})
self._postgresql_conf_mtime = pg_conf_mtime self._postgresql_conf_mtime = pg_conf_mtime
self._auto_conf_mtime = auto_conf_mtime self._auto_conf_mtime = auto_conf_mtime
self._postmaster_ctime = postmaster_ctime self._postmaster_ctime = postmaster_ctime
@@ -594,13 +648,13 @@ class ConfigHandler(object):
values = None values = None
return values, True return values, True
def _read_recovery_params_pre_v12(self): def _read_recovery_params_pre_v12(self) -> Tuple[Optional[CaseInsensitiveDict], Optional[bool]]:
recovery_conf_mtime = mtime(self._recovery_conf) recovery_conf_mtime = mtime(self._recovery_conf)
passfile_mtime = mtime(self._passfile) if self._passfile else False passfile_mtime = mtime(self._passfile) if self._passfile else False
if recovery_conf_mtime == self._recovery_conf_mtime and passfile_mtime == self._passfile_mtime: if recovery_conf_mtime == self._recovery_conf_mtime and passfile_mtime == self._passfile_mtime:
return None, False return None, False
values = {} values = CaseInsensitiveDict()
with open(self._recovery_conf, 'r') as f: with open(self._recovery_conf, 'r') as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -610,7 +664,7 @@ class ConfigHandler(object):
match = PARAMETER_RE.match(line) match = PARAMETER_RE.match(line)
if match: if match:
value = read_recovery_param_value(line[match.end():]) value = read_recovery_param_value(line[match.end():])
if value is None: if match is None or value is None:
return None, True return None, True
values[match.group(1)] = [value, True] values[match.group(1)] = [value, True]
self._recovery_conf_mtime = recovery_conf_mtime self._recovery_conf_mtime = recovery_conf_mtime
@@ -619,7 +673,7 @@ class ConfigHandler(object):
values.update({param: ['', True] for param in self._recovery_parameters_to_compare if param not in values}) values.update({param: ['', True] for param in self._recovery_parameters_to_compare if param not in values})
return values, True return values, True
def _check_passfile(self, passfile, wanted_primary_conninfo): def _check_passfile(self, passfile: str, wanted_primary_conninfo: Dict[str, Any]) -> bool:
# If there is a passfile in the primary_conninfo try to figure out that # If there is a passfile in the primary_conninfo try to figure out that
# the passfile contains the line(s) allowing connection to the given node. # the passfile contains the line(s) allowing connection to the given node.
# We assume that the passfile was created by Patroni and therefore doing # We assume that the passfile was created by Patroni and therefore doing
@@ -628,7 +682,7 @@ class ConfigHandler(object):
if passfile_mtime: if passfile_mtime:
try: try:
with open(passfile) as f: with open(passfile) as f:
wanted_lines = self._pgpass_line(wanted_primary_conninfo).splitlines() wanted_lines = (self._pgpass_line(wanted_primary_conninfo) or '').splitlines()
file_lines = f.read().splitlines() file_lines = f.read().splitlines()
if set(wanted_lines) == set(file_lines): if set(wanted_lines) == set(file_lines):
self._passfile = passfile self._passfile = passfile
@@ -638,7 +692,8 @@ class ConfigHandler(object):
logger.info('Failed to read %s', passfile) logger.info('Failed to read %s', passfile)
return False return False
def _check_primary_conninfo(self, primary_conninfo, wanted_primary_conninfo): def _check_primary_conninfo(self, primary_conninfo: Dict[str, Any],
wanted_primary_conninfo: Dict[str, Any]) -> bool:
# first we will cover corner cases, when we are replicating from somewhere while shouldn't # first we will cover corner cases, when we are replicating from somewhere while shouldn't
# or there is no primary_conninfo but we should replicate from some specific node. # or there is no primary_conninfo but we should replicate from some specific node.
if not wanted_primary_conninfo: if not wanted_primary_conninfo:
@@ -667,7 +722,7 @@ class ConfigHandler(object):
return all(str(primary_conninfo.get(p)) == str(v) for p, v in wanted_primary_conninfo.items() if v is not None) return all(str(primary_conninfo.get(p)) == str(v) for p, v in wanted_primary_conninfo.items() if v is not None)
def check_recovery_conf(self, member): def check_recovery_conf(self, member: Union[Leader, Member, None]) -> Tuple[bool, bool]:
"""Returns a tuple. The first boolean element indicates that recovery params don't match """Returns a tuple. The first boolean element indicates that recovery params don't match
and the second is set to `True` if the restart is required in order to apply new values""" and the second is set to `True` if the restart is required in order to apply new values"""
@@ -701,7 +756,7 @@ class ConfigHandler(object):
else: # empty string, primary_conninfo is not in the config else: # empty string, primary_conninfo is not in the config
primary_conninfo[0] = {} primary_conninfo[0] = {}
if not self._postgresql.is_starting(): if not self._postgresql.is_starting() and self._current_recovery_params:
# when wal receiver is alive take primary_slot_name from pg_stat_wal_receiver # when wal receiver is alive take primary_slot_name from pg_stat_wal_receiver
wal_receiver_primary_slot_name = self._postgresql.primary_slot_name() wal_receiver_primary_slot_name = self._postgresql.primary_slot_name()
if not wal_receiver_primary_slot_name and self._postgresql.primary_conninfo(): if not wal_receiver_primary_slot_name and self._postgresql.primary_conninfo():
@@ -715,11 +770,11 @@ class ConfigHandler(object):
and not self._postgresql.cb_called and not self._postgresql.cb_called
and not self._postgresql.is_starting())} and not self._postgresql.is_starting())}
def record_missmatch(mtype): def record_missmatch(mtype: bool) -> None:
required['restart' if mtype else 'reload'] += 1 required['restart' if mtype else 'reload'] += 1
wanted_recovery_params = self.build_recovery_params(member) wanted_recovery_params = self.build_recovery_params(member)
for param, value in self._current_recovery_params.items(): for param, value in (self._current_recovery_params or {}).items():
# Skip certain parameters defined in the included postgres config files # Skip certain parameters defined in the included postgres config files
# if we know that they are not specified in the patroni configuration. # if we know that they are not specified in the patroni configuration.
if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \ if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \
@@ -741,25 +796,25 @@ class ConfigHandler(object):
return required['restart'] + required['reload'] > 0, required['restart'] > 0 return required['restart'] + required['reload'] > 0, required['restart'] > 0
@staticmethod @staticmethod
def _remove_file_if_exists(name): def _remove_file_if_exists(name: str) -> None:
if os.path.isfile(name) or os.path.islink(name): if os.path.isfile(name) or os.path.islink(name):
os.unlink(name) os.unlink(name)
@staticmethod @staticmethod
def _pgpass_line(record): def _pgpass_line(record: Dict[str, Any]) -> Optional[str]:
if 'password' in record: if 'password' in record:
def escape(value): def escape(value: Any) -> str:
return re.sub(r'([:\\])', r'\\\1', str(value)) return re.sub(r'([:\\])', r'\\\1', str(value))
record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')} record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')}
# 'host' could be several comma-separated hostnames, in this case # 'host' could be several comma-separated hostnames, in this case
# we need to write on pgpass line per host # we need to write on pgpass line per host
line = '' line = ''
for hostname in record.get('host').split(','): for hostname in record['host'].split(','):
line += hostname + ':{port}:*:{user}:{password}'.format(**record) + '\n' line += hostname + ':{port}:*:{user}:{password}'.format(**record) + '\n'
return line.rstrip() return line.rstrip()
def write_pgpass(self, record): def write_pgpass(self, record: Dict[str, Any]) -> Dict[str, str]:
line = self._pgpass_line(record) line = self._pgpass_line(record)
if not line: if not line:
return os.environ.copy() return os.environ.copy()
@@ -770,37 +825,38 @@ class ConfigHandler(object):
return {**os.environ, 'PGPASSFILE': self._pgpass} return {**os.environ, 'PGPASSFILE': self._pgpass}
def write_recovery_conf(self, recovery_params): def write_recovery_conf(self, recovery_params: CaseInsensitiveDict) -> None:
self._recovery_params = 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()
self.set_file_permissions(self._standby_signal)
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.set_file_permissions(self._recovery_signal)
def restart_required(name): def restart_required(name: str) -> bool:
if self._postgresql.major_version >= 140000: if self._postgresql.major_version >= 140000:
return False return False
return name == 'restore_command' or (self._postgresql.major_version < 130000 return name == 'restore_command' or (self._postgresql.major_version < 130000
and name in ('primary_conninfo', 'primary_slot_name')) and name in ('primary_conninfo', 'primary_slot_name'))
self._current_recovery_params = {n: [v, restart_required(n), self._postgresql_conf] self._current_recovery_params = CaseInsensitiveDict({n: [v, restart_required(n), self._postgresql_conf]
for n, v in recovery_params.items()} for n, v in recovery_params.items()})
else: else:
with ConfigWriter(self._recovery_conf) as f: with self.config_writer(self._recovery_conf) as f:
os.chmod(self._recovery_conf, stat.S_IWRITE | stat.S_IREAD)
self._write_recovery_params(f, recovery_params) self._write_recovery_params(f, recovery_params)
def remove_recovery_conf(self): def remove_recovery_conf(self) -> None:
for name in (self._recovery_conf, self._standby_signal, self._recovery_signal): for name in (self._recovery_conf, self._standby_signal, self._recovery_signal):
self._remove_file_if_exists(name) self._remove_file_if_exists(name)
self._recovery_params = {} self._recovery_params = CaseInsensitiveDict()
self._current_recovery_params = None self._current_recovery_params = None
def _sanitize_auto_conf(self): def _sanitize_auto_conf(self) -> None:
overwrite = False overwrite = False
lines = [] lines: List[str] = []
if os.path.exists(self._auto_conf): if os.path.exists(self._auto_conf):
try: try:
@@ -818,12 +874,13 @@ class ConfigHandler(object):
if overwrite: if overwrite:
try: try:
with open(self._auto_conf, 'w') as f: with open(self._auto_conf, 'w') as f:
self.set_file_permissions(self._auto_conf)
for raw_line in lines: for raw_line in lines:
f.write(raw_line) f.write(raw_line)
except Exception: except Exception:
logger.exception('Failed to remove some unwanted parameters from %s', self._auto_conf) logger.exception('Failed to remove some unwanted parameters from %s', self._auto_conf)
def _adjust_recovery_parameters(self): def _adjust_recovery_parameters(self) -> None:
# It is not strictly necessary, but we can make patroni configs crossi-compatible with all postgres versions. # It is not strictly necessary, but we can make patroni configs crossi-compatible with all postgres versions.
recovery_conf = {n: v for n, v in self._server_parameters.items() if n.lower() in self._RECOVERY_PARAMETERS} recovery_conf = {n: v for n, v in self._server_parameters.items() if n.lower() in self._RECOVERY_PARAMETERS}
if recovery_conf: if recovery_conf:
@@ -834,13 +891,14 @@ class ConfigHandler(object):
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: Dict[str, Any]) -> CaseInsensitiveDict:
parameters = config['parameters'].copy() parameters = config['parameters'].copy()
listen_addresses, port = split_host_port(config['listen'], 5432) listen_addresses, port = split_host_port(config['listen'], 5432)
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port)) parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
if config.get('synchronous_mode', False): if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode:
if self._synchronous_standby_names is None: if self._synchronous_standby_names is None:
if config.get('synchronous_mode_strict', False): if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\
and self._postgresql.role in ('master', 'primary', 'promoted'):
parameters['synchronous_standby_names'] = '*' parameters['synchronous_standby_names'] = '*'
else: else:
parameters.pop('synchronous_standby_names', None) parameters.pop('synchronous_standby_names', None)
@@ -859,24 +917,24 @@ class ConfigHandler(object):
parameters.setdefault('wal_keep_size', str(int(wal_keep_segments) * 16) + 'MB') parameters.setdefault('wal_keep_size', str(int(wal_keep_segments) * 16) + 'MB')
elif self._postgresql.major_version: elif self._postgresql.major_version:
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB') wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
parameters.setdefault('wal_keep_segments', int((wal_keep_size + 8) / 16)) parameters.setdefault('wal_keep_segments', int(((wal_keep_size or 0) + 8) / 16))
self._postgresql.citus_handler.adjust_postgres_gucs(parameters) self._postgresql.citus_handler.adjust_postgres_gucs(parameters)
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version or ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version
self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]}) or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
ret.update({k: os.path.join(self._config_dir, ret[k]) for k in ('hba_file', 'ident_file') if k in ret}) ret.update({k: os.path.join(self._config_dir, ret[k]) for k in ('hba_file', 'ident_file') if k in ret})
return ret return ret
@staticmethod @staticmethod
def _get_unix_local_address(unix_socket_directories): def _get_unix_local_address(unix_socket_directories: str) -> str:
for d in unix_socket_directories.split(','): for d in unix_socket_directories.split(','):
d = d.strip() d = d.strip()
if d.startswith('/'): # Only absolute path can be used to connect via unix-socket if d.startswith('/'): # Only absolute path can be used to connect via unix-socket
return d return d
return '' return ''
def _get_tcp_local_address(self): def _get_tcp_local_address(self) -> str:
listen_addresses = self._server_parameters['listen_addresses'].split(',') listen_addresses = self._server_parameters['listen_addresses'].split(',')
for la in listen_addresses: for la in listen_addresses:
@@ -885,7 +943,7 @@ class ConfigHandler(object):
return listen_addresses[0].strip() # can't use localhost, take first address from listen_addresses return listen_addresses[0].strip() # can't use localhost, take first address from listen_addresses
@property @property
def local_connect_kwargs(self): def local_connect_kwargs(self) -> Dict[str, Any]:
ret = self._local_address.copy() ret = self._local_address.copy()
# add all of the other connection settings that are available # add all of the other connection settings that are available
ret.update(self._superuser) ret.update(self._superuser)
@@ -901,7 +959,7 @@ class ConfigHandler(object):
'options': '-c statement_timeout=2000'}) 'options': '-c statement_timeout=2000'})
return ret return ret
def resolve_connection_addresses(self): def resolve_connection_addresses(self) -> None:
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()
netloc = self._config.get('connect_address') or tcp_local_address + ':' + port netloc = self._config.get('connect_address') or tcp_local_address + ':' + port
@@ -921,19 +979,24 @@ class ConfigHandler(object):
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database) 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: Collection[str]
) -> Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]]:
return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile' return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile'
+ ' FROM pg_catalog.pg_settings ' + + ' FROM pg_catalog.pg_settings '
' WHERE pg_catalog.lower(name) = ANY(%s)'), + ' WHERE pg_catalog.lower(name) = ANY(%s)'),
[n.lower() for n in names])} [n.lower() for n in names])}
@staticmethod @staticmethod
def _handle_wal_buffers(old_values, changes): def _handle_wal_buffers(old_values: Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]],
wal_block_size = parse_int(old_values['wal_block_size'][1]) changes: CaseInsensitiveDict) -> None:
wal_block_size = parse_int(old_values['wal_block_size'][1]) or 8192
wal_segment_size = old_values['wal_segment_size'] wal_segment_size = old_values['wal_segment_size']
wal_segment_unit = parse_int(wal_segment_size[2], 'B') if wal_segment_size[2][0].isdigit() else 1 wal_segment_unit = parse_int(wal_segment_size[2], 'B') or 8192 \
wal_segment_size = parse_int(wal_segment_size[1]) * wal_segment_unit / wal_block_size if wal_segment_size[2] is not None and wal_segment_size[2][0].isdigit() else 1
default_wal_buffers = min(max(parse_int(old_values['shared_buffers'][1]) / 32, 8), wal_segment_size) wal_segment_size = parse_int(wal_segment_size[1]) or (16777216 if wal_segment_size[2] is None else 2048)
wal_segment_size *= wal_segment_unit / wal_block_size
default_wal_buffers = min(max((parse_int(old_values['shared_buffers'][1]) or 16384) / 32, 8), wal_segment_size)
wal_buffers = old_values['wal_buffers'] wal_buffers = old_values['wal_buffers']
new_value = str(changes['wal_buffers'] or -1) new_value = str(changes['wal_buffers'] or -1)
@@ -944,7 +1007,7 @@ class ConfigHandler(object):
if new_value == old_value: if new_value == old_value:
del changes['wal_buffers'] del changes['wal_buffers']
def reload_config(self, config, sighup=False): def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
self._superuser = config['authentication'].get('superuser', {}) self._superuser = config['authentication'].get('superuser', {})
server_parameters = self.get_server_parameters(config) server_parameters = self.get_server_parameters(config)
@@ -955,6 +1018,7 @@ class ConfigHandler(object):
changes.update({p: None for p in self._server_parameters.keys() changes.update({p: None for p in self._server_parameters.keys()
if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)}) if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)})
if changes: if changes:
undef = []
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
undef = [p for p in ('shared_buffers', 'wal_segment_size', 'wal_block_size') if p not in changes] undef = [p for p in ('shared_buffers', 'wal_segment_size', 'wal_block_size') if p not in changes]
changes.update({p: None for p in undef}) changes.update({p: None for p in undef})
@@ -1029,16 +1093,17 @@ class ConfigHandler(object):
if self._postgresql.major_version >= 90500: if self._postgresql.major_version >= 90500:
time.sleep(1) time.sleep(1)
try: try:
pending_restart = self._postgresql.query( pending_restart = (self._postgresql.query(
'SELECT COUNT(*) FROM pg_catalog.pg_settings WHERE pg_catalog.lower(name) != ALL(%s)' 'SELECT COUNT(*) FROM pg_catalog.pg_settings'
' AND pending_restart', [n.lower() for n in self._RECOVERY_PARAMETERS]).fetchone()[0] > 0 ' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in self._RECOVERY_PARAMETERS]).fetchone() or (0,))[0] > 0
self._postgresql.set_pending_restart(pending_restart) self._postgresql.set_pending_restart(pending_restart)
except Exception as e: except Exception as e:
logger.warning('Exception %r when running query', e) logger.warning('Exception %r when running query', e)
else: else:
logger.info('No PostgreSQL configuration items changed, nothing to reload.') logger.info('No PostgreSQL configuration items changed, nothing to reload.')
def set_synchronous_standby_names(self, value): def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
"""Updates synchronous_standby_names and reloads if necessary. """Updates synchronous_standby_names and reloads if necessary.
:returns: True if value was updated.""" :returns: True if value was updated."""
if value != self._synchronous_standby_names: if value != self._synchronous_standby_names:
@@ -1053,7 +1118,7 @@ class ConfigHandler(object):
return True return True
@property @property
def effective_configuration(self): def effective_configuration(self) -> CaseInsensitiveDict:
"""It might happen that the current value of one (or more) below parameters stored in """It might happen that the current value of one (or more) below parameters stored in
the controldata is higher than the value stored in the global cluster configuration. the controldata is higher than the value stored in the global cluster configuration.
@@ -1088,7 +1153,7 @@ class ConfigHandler(object):
continue continue
cvalue = parse_int(data[cname]) cvalue = parse_int(data[cname])
if cvalue > value: if cvalue is not None and value is not None and cvalue > value:
effective_configuration[name] = cvalue effective_configuration[name] = cvalue
self._postgresql.set_pending_restart(True) self._postgresql.set_pending_restart(True)
@@ -1112,35 +1177,38 @@ class ConfigHandler(object):
return effective_configuration return effective_configuration
@property @property
def replication(self): def replication(self) -> Dict[str, Any]:
return self._config['authentication']['replication'] return self._config['authentication']['replication']
@property @property
def superuser(self): def superuser(self) -> Dict[str, Any]:
return self._superuser return self._superuser
@property @property
def rewind_credentials(self): def rewind_credentials(self) -> Dict[str, Any]:
return self._config['authentication'].get('rewind', self._superuser) \ return self._config['authentication'].get('rewind', self._superuser) \
if self._postgresql.major_version >= 110000 else self._superuser if self._postgresql.major_version >= 110000 else self._superuser
@property @property
def ident_file(self): def ident_file(self) -> Optional[str]:
ident_file = self._server_parameters.get('ident_file') ident_file = self._server_parameters.get('ident_file')
return None if ident_file == self._pg_ident_conf else ident_file return None if ident_file == self._pg_ident_conf else ident_file
@property @property
def hba_file(self): def hba_file(self) -> Optional[str]:
hba_file = self._server_parameters.get('hba_file') hba_file = self._server_parameters.get('hba_file')
return None if hba_file == self._pg_hba_conf else hba_file return None if hba_file == self._pg_hba_conf else hba_file
@property @property
def pg_hba_conf(self): def pg_hba_conf(self) -> str:
return self._pg_hba_conf return self._pg_hba_conf
@property @property
def postgresql_conf(self): def postgresql_conf(self) -> str:
return self._postgresql_conf return self._postgresql_conf
def get(self, key, default=None): def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]:
return self._config.get(key, default) return self._config.get(key, default)
def restore_command(self) -> Optional[str]:
return (self.get('recovery_conf') or {}).get('restore_command')
+12 -7
View File
@@ -2,6 +2,10 @@ import logging
from contextlib import contextmanager from contextlib import contextmanager
from threading import Lock from threading import Lock
from typing import Any, Dict, Iterator, Union, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from psycopg import Connection as Connection3, Cursor
from psycopg2 import connection, cursor
from .. import psycopg from .. import psycopg
@@ -9,29 +13,30 @@ logger = logging.getLogger(__name__)
class Connection(object): class Connection(object):
server_version: int
def __init__(self): def __init__(self) -> None:
self._lock = Lock() self._lock = Lock()
self._connection = None self._connection = None
self._cursor_holder = None self._cursor_holder = None
def set_conn_kwargs(self, conn_kwargs): def set_conn_kwargs(self, conn_kwargs: Dict[str, Any]) -> None:
self._conn_kwargs = conn_kwargs self._conn_kwargs = conn_kwargs
def get(self): def get(self) -> Union['connection', 'Connection3[Any]']:
with self._lock: with self._lock:
if not self._connection or self._connection.closed != 0: if not self._connection or self._connection.closed != 0:
self._connection = psycopg.connect(**self._conn_kwargs) self._connection = psycopg.connect(**self._conn_kwargs)
self.server_version = self._connection.server_version self.server_version = getattr(self._connection, 'server_version', 0)
return self._connection return self._connection
def cursor(self): def cursor(self) -> Union['cursor', 'Cursor[Any]']:
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0: if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
logger.info("establishing a new patroni connection to the postgres cluster") logger.info("establishing a new patroni connection to the postgres cluster")
self._cursor_holder = self.get().cursor() self._cursor_holder = self.get().cursor()
return self._cursor_holder return self._cursor_holder
def close(self): def close(self) -> None:
if self._connection and self._connection.closed == 0: if self._connection and self._connection.closed == 0:
self._connection.close() self._connection.close()
logger.info("closed patroni connection to the postgresql cluster") logger.info("closed patroni connection to the postgresql cluster")
@@ -39,7 +44,7 @@ class Connection(object):
@contextmanager @contextmanager
def get_connection_cursor(**kwargs): def get_connection_cursor(**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
conn = psycopg.connect(**kwargs) conn = psycopg.connect(**kwargs)
with conn.cursor() as cur: with conn.cursor() as cur:
yield cur yield cur
+8 -8
View File
@@ -2,7 +2,9 @@ import errno
import logging import logging
import os import os
from patroni.exceptions import PostgresException from typing import Iterable, Tuple
from ..exceptions import PostgresException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,29 +57,27 @@ def postgres_major_version_to_int(pg_version: str) -> int:
return postgres_version_to_int(pg_version + '.0') return postgres_version_to_int(pg_version + '.0')
def parse_lsn(lsn): def parse_lsn(lsn: str) -> int:
t = lsn.split('/') t = lsn.split('/')
return int(t[0], 16) * 0x100000000 + int(t[1], 16) return int(t[0], 16) * 0x100000000 + int(t[1], 16)
def parse_history(data): def parse_history(data: str) -> Iterable[Tuple[int, int, str]]:
for line in data.split('\n'): for line in data.split('\n'):
values = line.strip().split('\t') values = line.strip().split('\t')
if len(values) == 3: if len(values) == 3:
try: try:
values[0] = int(values[0]) yield int(values[0]), parse_lsn(values[1]), values[2]
values[1] = parse_lsn(values[1])
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): def format_lsn(lsn: int, full: bool = False) -> str:
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}' template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
return template.format(lsn >> 32, lsn & 0xFFFFFFFF) return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
def fsync_dir(path): def fsync_dir(path: str) -> None:
if os.name != 'nt': if os.name != 'nt':
fd = os.open(path, os.O_DIRECTORY) fd = os.open(path, os.O_DIRECTORY)
try: try:
+21 -17
View File
@@ -7,6 +7,9 @@ import signal
import subprocess import subprocess
import sys import sys
from multiprocessing.connection import Connection
from typing import Dict, Optional, List
from patroni import PATRONI_ENV_PREFIX, KUBERNETES_ENV_PREFIX from patroni import PATRONI_ENV_PREFIX, KUBERNETES_ENV_PREFIX
# avoid spawning the resource tracker process # avoid spawning the resource tracker process
@@ -26,7 +29,7 @@ STOP_SIGNALS = {
} }
def pg_ctl_start(conn, cmdline, env): def pg_ctl_start(conn: Connection, cmdline: List[str], env: Dict[str, str]) -> None:
if os.name != 'nt': if os.name != 'nt':
os.setsid() os.setsid()
try: try:
@@ -40,7 +43,8 @@ def pg_ctl_start(conn, cmdline, env):
class PostmasterProcess(psutil.Process): class PostmasterProcess(psutil.Process):
def __init__(self, pid): def __init__(self, pid: int) -> None:
self._postmaster_pid: Dict[str, str]
self.is_single_user = False self.is_single_user = False
if pid < 0: if pid < 0:
pid = -pid pid = -pid
@@ -48,7 +52,7 @@ class PostmasterProcess(psutil.Process):
super(PostmasterProcess, self).__init__(pid) super(PostmasterProcess, self).__init__(pid)
@staticmethod @staticmethod
def _read_postmaster_pidfile(data_dir): def _read_postmaster_pidfile(data_dir: str) -> Dict[str, str]:
"""Reads and parses postmaster.pid from the data directory """Reads and parses postmaster.pid from the data directory
:returns dictionary of values if successful, empty dictionary otherwise :returns dictionary of values if successful, empty dictionary otherwise
@@ -60,7 +64,7 @@ class PostmasterProcess(psutil.Process):
except IOError: except IOError:
return {} return {}
def _is_postmaster_process(self): def _is_postmaster_process(self) -> bool:
try: try:
start_time = int(self._postmaster_pid.get('start_time', 0)) start_time = int(self._postmaster_pid.get('start_time', 0))
if start_time and abs(self.create_time() - start_time) > 3: if start_time and abs(self.create_time() - start_time) > 3:
@@ -79,7 +83,7 @@ class PostmasterProcess(psutil.Process):
return True return True
@classmethod @classmethod
def _from_pidfile(cls, data_dir): def _from_pidfile(cls, data_dir: str) -> Optional['PostmasterProcess']:
postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir) postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir)
try: try:
pid = int(postmaster_pid.get('pid', 0)) pid = int(postmaster_pid.get('pid', 0))
@@ -88,10 +92,10 @@ class PostmasterProcess(psutil.Process):
proc._postmaster_pid = postmaster_pid proc._postmaster_pid = postmaster_pid
return proc return proc
except ValueError: except ValueError:
pass return None
@staticmethod @staticmethod
def from_pidfile(data_dir): def from_pidfile(data_dir: str) -> Optional['PostmasterProcess']:
try: try:
proc = PostmasterProcess._from_pidfile(data_dir) proc = PostmasterProcess._from_pidfile(data_dir)
return proc if proc and proc._is_postmaster_process() else None return proc if proc and proc._is_postmaster_process() else None
@@ -99,13 +103,13 @@ class PostmasterProcess(psutil.Process):
return None return None
@classmethod @classmethod
def from_pid(cls, pid): def from_pid(cls, pid: int) -> Optional['PostmasterProcess']:
try: try:
return cls(pid) return cls(pid)
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
return None return None
def signal_kill(self): def signal_kill(self) -> bool:
"""to suspend and kill postmaster and all children """to suspend and kill postmaster and all children
:returns True if postmaster and children are killed, False if error :returns True if postmaster and children are killed, False if error
@@ -141,7 +145,7 @@ class PostmasterProcess(psutil.Process):
psutil.wait_procs(children + [self]) psutil.wait_procs(children + [self])
return True return True
def signal_stop(self, mode, pg_ctl='pg_ctl'): def signal_stop(self, mode: str, pg_ctl: str = 'pg_ctl') -> Optional[bool]:
"""Signal postmaster process to stop """Signal postmaster process to stop
:returns None if signaled, True if process is already gone, False if error :returns None if signaled, True if process is already gone, False if error
@@ -161,7 +165,7 @@ class PostmasterProcess(psutil.Process):
return None return None
def pg_ctl_kill(self, mode, pg_ctl): def pg_ctl_kill(self, mode: str, pg_ctl: str) -> Optional[bool]:
try: try:
status = subprocess.call([pg_ctl, "kill", STOP_SIGNALS[mode], str(self.pid)]) status = subprocess.call([pg_ctl, "kill", STOP_SIGNALS[mode], str(self.pid)])
except OSError: except OSError:
@@ -171,7 +175,7 @@ class PostmasterProcess(psutil.Process):
else: else:
return not self.is_running() return not self.is_running()
def wait_for_user_backends_to_close(self, stop_timeout): def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15 # These regexps are cross checked against versions PostgreSQL 9.1 .. 15
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|" aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|" "checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
@@ -183,8 +187,8 @@ class PostmasterProcess(psutil.Process):
except psutil.Error: except psutil.Error:
return logger.debug('Failed to get list of postmaster children') return logger.debug('Failed to get list of postmaster children')
user_backends = [] user_backends: List[psutil.Process] = []
user_backends_cmdlines = {} user_backends_cmdlines: Dict[int, str] = {}
for child in children: for child in children:
try: try:
cmdline = child.cmdline() cmdline = child.cmdline()
@@ -195,7 +199,7 @@ class PostmasterProcess(psutil.Process):
pass pass
if user_backends: if user_backends:
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values())) logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values()))
gone, live = psutil.wait_procs(user_backends, stop_timeout) _, live = psutil.wait_procs(user_backends, stop_timeout)
if stop_timeout and live: if stop_timeout and live:
live = [user_backends_cmdlines[b.pid] for b in live] live = [user_backends_cmdlines[b.pid] for b in live]
logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live)) logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live))
@@ -203,7 +207,7 @@ class PostmasterProcess(psutil.Process):
logger.debug("Backends closed") logger.debug("Backends closed")
@staticmethod @staticmethod
def start(pgcommand, data_dir, conf, options): def start(pgcommand: str, data_dir: str, conf: str, options: List[str]) -> Optional['PostmasterProcess']:
# Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information # Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information
# it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start # it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start
# in python. It will start postgres, wait for port to be open and wait until postgres will start # in python. It will start postgres, wait for port to be open and wait until postgres will start
@@ -234,7 +238,7 @@ class PostmasterProcess(psutil.Process):
pass pass
cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options
logger.debug("Starting postgres: %s", " ".join(cmdline)) logger.debug("Starting postgres: %s", " ".join(cmdline))
ctx = multiprocessing.get_context('spawn') if sys.version_info >= (3, 4) else multiprocessing ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe(False) parent_conn, child_conn = ctx.Pipe(False)
proc = ctx.Process(target=pg_ctl_start, args=(child_conn, cmdline, env)) proc = ctx.Process(target=pg_ctl_start, args=(child_conn, cmdline, env))
proc.start() proc.start()
+95 -75
View File
@@ -5,36 +5,46 @@ import shlex
import shutil import shutil
import subprocess import subprocess
from enum import IntEnum
from threading import Lock, Thread from threading import Lock, Thread
from typing import Any, Callable, Dict, List, Optional, Union, Tuple
from . import Postgresql
from .connection import get_connection_cursor from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir, parse_history, parse_lsn from .misc import format_lsn, fsync_dir, parse_history, parse_lsn
from ..async_executor import CriticalTask from ..async_executor import CriticalTask
from ..dcs import Leader from ..dcs import Leader, RemoteMember
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECKPOINT': 1, 'CHECK': 2, 'NEED': 3,
'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6}) class REWIND_STATUS(IntEnum):
INITIAL = 0
CHECKPOINT = 1
CHECK = 2
NEED = 3
NOT_NEED = 4
SUCCESS = 5
FAILED = 6
class Rewind(object): class Rewind(object):
def __init__(self, postgresql): def __init__(self, postgresql: Postgresql) -> None:
self._postgresql = postgresql self._postgresql = postgresql
self._checkpoint_task_lock = Lock() self._checkpoint_task_lock = Lock()
self.reset_state() self.reset_state()
@staticmethod @staticmethod
def configuration_allows_rewind(data): def configuration_allows_rewind(data: Dict[str, str]) -> bool:
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0' return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
@property @property
def enabled(self): def enabled(self) -> bool:
return self._postgresql.config.get('use_pg_rewind') return bool(self._postgresql.config.get('use_pg_rewind'))
@property @property
def can_rewind(self): def can_rewind(self) -> bool:
""" check if pg_rewind executable is there and that pg_controldata indicates """ check if pg_rewind executable is there and that pg_controldata indicates
we have either wal_log_hints or checksums turned on we have either wal_log_hints or checksums turned on
""" """
@@ -52,43 +62,45 @@ class Rewind(object):
return self.configuration_allows_rewind(self._postgresql.controldata()) return self.configuration_allows_rewind(self._postgresql.controldata())
@property @property
def should_remove_data_directory_on_diverged_timelines(self): def should_remove_data_directory_on_diverged_timelines(self) -> bool:
return self._postgresql.config.get('remove_data_directory_on_diverged_timelines') return bool(self._postgresql.config.get('remove_data_directory_on_diverged_timelines'))
@property @property
def can_rewind_or_reinitialize_allowed(self): def can_rewind_or_reinitialize_allowed(self) -> bool:
return self.should_remove_data_directory_on_diverged_timelines or self.can_rewind return self.should_remove_data_directory_on_diverged_timelines or self.can_rewind
def trigger_check_diverged_lsn(self): def trigger_check_diverged_lsn(self) -> None:
if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED: if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED:
self._state = REWIND_STATUS.CHECK self._state = REWIND_STATUS.CHECK
@staticmethod @staticmethod
def check_leader_is_not_in_recovery(conn_kwargs): def check_leader_is_not_in_recovery(conn_kwargs: Dict[str, Any]) -> Optional[bool]:
try: try:
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur: with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
cur.execute('SELECT pg_catalog.pg_is_in_recovery()') cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
if not cur.fetchone()[0]: row = cur.fetchone()
if not row or not row[0]:
return True return True
logger.info('Leader is still in_recovery and therefore can\'t be used for rewind') logger.info('Leader is still in_recovery and therefore can\'t be used for rewind')
except Exception: except Exception:
return logger.exception('Exception when working with leader') return logger.exception('Exception when working with leader')
@staticmethod @staticmethod
def check_leader_has_run_checkpoint(conn_kwargs): def check_leader_has_run_checkpoint(conn_kwargs: Dict[str, Any]) -> Optional[str]:
try: try:
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur: with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()" + cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()"
" AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name(" + " AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name("
" pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id" + " pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id"
" FROM pg_catalog.pg_control_checkpoint()") " FROM pg_catalog.pg_control_checkpoint()")
if not cur.fetchone()[0]: row = cur.fetchone()
if not row or not row[0]:
return 'leader has not run a checkpoint yet' return 'leader has not run a checkpoint yet'
except Exception: except Exception:
logger.exception('Exception when working with leader') logger.exception('Exception when working with leader')
return 'not accessible or not healty' return 'not accessible or not healty'
def _get_checkpoint_end(self, timeline, lsn): def _get_checkpoint_end(self, timeline: int, lsn: int) -> int:
"""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 will 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:
@@ -96,12 +108,12 @@ class Rewind(object):
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."""
lsn8 = format_lsn(lsn, True) lsn8 = format_lsn(lsn, True)
lsn = format_lsn(lsn) lsn_str = format_lsn(lsn)
out, err = self._postgresql.waldump(timeline, lsn, 2) out, err = self._postgresql.waldump(timeline, lsn_str, 2)
if out is not None and err is not None: if out is not None and err is not None:
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_str)
if len(out) == 1 and len(err) == 1 and ', lsn: {0}, prev '.format(lsn8) in out[0] and pattern in err[0]: if len(out) == 1 and len(err) == 1 and ', lsn: {0}, prev '.format(lsn8) in out[0] and pattern in err[0]:
i = err[0].find(pattern) + len(pattern) i = err[0].find(pattern) + len(pattern)
@@ -117,20 +129,20 @@ class Rewind(object):
return 0 return 0
def _get_local_timeline_lsn_from_controldata(self): def _get_local_timeline_lsn_from_controldata(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
in_recovery = timeline = lsn = None in_recovery = timeline = lsn = None
data = self._postgresql.controldata() data = self._postgresql.controldata()
try: try:
if data.get('Database cluster state') in ('shut down in recovery', 'in archive recovery'): if data.get('Database cluster state') in ('shut down in recovery', 'in archive recovery'):
in_recovery = True in_recovery = True
lsn = data.get('Minimum recovery ending location') lsn = data.get('Minimum recovery ending location')
timeline = int(data.get("Min recovery ending loc's timeline")) timeline = int(data.get("Min recovery ending loc's timeline", ""))
if lsn == '0/0' or timeline == 0: # it was a primary when it crashed if lsn == '0/0' or timeline == 0: # it was a primary when it crashed
data['Database cluster state'] = 'shut down' data['Database cluster state'] = 'shut down'
if data.get('Database cluster state') == 'shut down': if data.get('Database cluster state') == 'shut down':
in_recovery = False in_recovery = False
lsn = data.get('Latest checkpoint location') lsn = data.get('Latest checkpoint location')
timeline = int(data.get("Latest checkpoint's TimeLineID")) timeline = int(data.get("Latest checkpoint's TimeLineID", ""))
except (TypeError, ValueError): except (TypeError, ValueError):
logger.exception('Failed to get local timeline and lsn from pg_controldata output') logger.exception('Failed to get local timeline and lsn from pg_controldata output')
@@ -143,7 +155,7 @@ class Rewind(object):
return in_recovery, timeline, lsn return in_recovery, timeline, lsn
def _get_local_timeline_lsn(self): def _get_local_timeline_lsn(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection
in_recovery = True in_recovery = True
timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline() timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline()
@@ -156,14 +168,15 @@ class Rewind(object):
return in_recovery, timeline, lsn return in_recovery, timeline, lsn
@staticmethod @staticmethod
def _log_primary_history(history, i): def _log_primary_history(history: List[Tuple[int, int, str]], i: int) -> None:
start = max(0, i - 3) start = max(0, i - 3)
end = None if i + 4 >= len(history) else i + 2 end = None if i + 4 >= len(history) else i + 2
history_show = [] history_show: List[str] = []
def format_history_line(line): def format_history_line(line: Tuple[int, int, str]) -> str:
return '{0}\t{1}\t{2}'.format(line[0], format_lsn(line[1]), line[2]) return '{0}\t{1}\t{2}'.format(line[0], format_lsn(line[1]), line[2])
line = None
for line in history[start:end]: for line in history[start:end]:
history_show.append(format_history_line(line)) history_show.append(format_history_line(line))
@@ -173,7 +186,7 @@ class Rewind(object):
logger.info('primary: history=%s', '\n'.join(history_show)) logger.info('primary: history=%s', '\n'.join(history_show))
def _conn_kwargs(self, member, auth): def _conn_kwargs(self, member: Union[Leader, RemoteMember], auth: Dict[str, Any]) -> Dict[str, Any]:
ret = member.conn_kwargs(auth) ret = member.conn_kwargs(auth)
if not ret.get('dbname'): if not ret.get('dbname'):
ret['dbname'] = self._postgresql.database ret['dbname'] = self._postgresql.database
@@ -183,7 +196,7 @@ class Rewind(object):
ret['target_session_attrs'] = 'read-write' ret['target_session_attrs'] = 'read-write'
return ret return ret
def _check_timeline_and_lsn(self, leader): def _check_timeline_and_lsn(self, leader: Union[Leader, RemoteMember]) -> None:
in_recovery, local_timeline, local_lsn = self._get_local_timeline_lsn() in_recovery, local_timeline, local_lsn = self._get_local_timeline_lsn()
if local_timeline is None or local_lsn is None: if local_timeline is None or local_lsn is None:
return return
@@ -205,23 +218,28 @@ class Rewind(object):
try: try:
with self._postgresql.get_replication_connection_cursor(**leader.conn_kwargs()) as cur: with self._postgresql.get_replication_connection_cursor(**leader.conn_kwargs()) as cur:
cur.execute('IDENTIFY_SYSTEM') cur.execute('IDENTIFY_SYSTEM')
primary_timeline = cur.fetchone()[1] row = cur.fetchone()
logger.info('primary_timeline=%s', primary_timeline) if row:
if local_timeline > primary_timeline: # Not always supported by pg_rewind primary_timeline = row[1]
need_rewind = True logger.info('primary_timeline=%s', primary_timeline)
elif local_timeline == primary_timeline: if local_timeline > primary_timeline: # Not always supported by pg_rewind
need_rewind = False need_rewind = True
elif primary_timeline > 1: elif local_timeline == primary_timeline:
cur.execute('TIMELINE_HISTORY {0}'.format(primary_timeline)) need_rewind = False
history = cur.fetchone()[1] elif primary_timeline > 1:
if not isinstance(history, str): cur.execute('TIMELINE_HISTORY {0}'.format(primary_timeline).encode('utf-8'))
history = bytes(history).decode('utf-8') row = cur.fetchone()
logger.debug('primary: history=%s', history) if row:
history = row[1]
if not isinstance(history, str):
history = bytes(history).decode('utf-8')
logger.debug('primary: history=%s', history)
except Exception: except Exception:
return logger.exception('Exception when working with primary via replication connection') return logger.exception('Exception when working with primary via replication connection')
if history is not None: if history is not None:
history = list(parse_history(history)) history = list(parse_history(history))
i = len(history)
for i, (parent_timeline, switchpoint, _) in enumerate(history): for i, (parent_timeline, switchpoint, _) in enumerate(history):
if parent_timeline == local_timeline: if parent_timeline == local_timeline:
# We don't need to rewind when: # We don't need to rewind when:
@@ -243,12 +261,12 @@ class Rewind(object):
self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
def rewind_or_reinitialize_needed_and_possible(self, leader): def rewind_or_reinitialize_needed_and_possible(self, leader: Union[Leader, RemoteMember, None]) -> bool:
if leader and leader.name != self._postgresql.name and leader.conn_url and self._state == REWIND_STATUS.CHECK: if leader and leader.name != self._postgresql.name and leader.conn_url and self._state == REWIND_STATUS.CHECK:
self._check_timeline_and_lsn(leader) self._check_timeline_and_lsn(leader)
return leader and leader.conn_url and self._state == REWIND_STATUS.NEED return bool(leader and leader.conn_url) and self._state == REWIND_STATUS.NEED
def __checkpoint(self, task, wakeup): def __checkpoint(self, task: CriticalTask, wakeup: Callable[..., Any]) -> None:
try: try:
result = self._postgresql.checkpoint() result = self._postgresql.checkpoint()
except Exception as e: except Exception as e:
@@ -258,11 +276,11 @@ class Rewind(object):
if task.result: if task.result:
wakeup() wakeup()
def ensure_checkpoint_after_promote(self, wakeup): def ensure_checkpoint_after_promote(self, wakeup: Callable[..., Any]) -> None:
"""After promote issue a CHECKPOINT from a new thread and asynchronously check the result. """After promote issue a CHECKPOINT from a new thread and asynchronously check the result.
In case if CHECKPOINT failed, just check that timeline in pg_control was updated.""" In case if CHECKPOINT failed, just check that timeline in pg_control was updated."""
if self._state == REWIND_STATUS.INITIAL and self._postgresql.is_leader(): if self._state != REWIND_STATUS.CHECKPOINT and self._postgresql.is_leader():
with self._checkpoint_task_lock: with self._checkpoint_task_lock:
if self._checkpoint_task: if self._checkpoint_task:
with self._checkpoint_task: with self._checkpoint_task:
@@ -275,10 +293,10 @@ class Rewind(object):
self._checkpoint_task = CriticalTask() self._checkpoint_task = CriticalTask()
Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start() Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
def checkpoint_after_promote(self): def checkpoint_after_promote(self) -> bool:
return self._state == REWIND_STATUS.CHECKPOINT return self._state == REWIND_STATUS.CHECKPOINT
def _buid_archiver_command(self, command, wal_filename): def _buid_archiver_command(self, command: str, wal_filename: str) -> str:
"""Replace placeholders in the given archiver command's template. """Replace placeholders in the given archiver command's template.
Applicable for archive_command and restore_command. Applicable for archive_command and restore_command.
Can also be used for archive_cleanup_command and recovery_end_command, Can also be used for archive_cleanup_command and recovery_end_command,
@@ -306,13 +324,13 @@ class Rewind(object):
return cmd return cmd
def _fetch_missing_wal(self, restore_command, wal_filename): def _fetch_missing_wal(self, restore_command: str, wal_filename: str) -> bool:
cmd = self._buid_archiver_command(restore_command, wal_filename) cmd = self._buid_archiver_command(restore_command, wal_filename)
logger.info('Trying to fetch the missing wal: %s', cmd) logger.info('Trying to fetch the missing wal: %s', cmd)
return self._postgresql.cancellable.call(shlex.split(cmd)) == 0 return self._postgresql.cancellable.call(shlex.split(cmd)) == 0
def _find_missing_wal(self, data): def _find_missing_wal(self, data: bytes) -> Optional[str]:
# could not open file "$PGDATA/pg_wal/0000000A00006AA100000068": No such file or directory # could not open file "$PGDATA/pg_wal/0000000A00006AA100000068": No such file or directory
pattern = 'could not open file "' pattern = 'could not open file "'
for line in data.decode('utf-8').split('\n'): for line in data.decode('utf-8').split('\n'):
@@ -325,7 +343,7 @@ class Rewind(object):
if waldir.endswith('/pg_' + self._postgresql.wal_name) and len(wal_filename) == 24: if waldir.endswith('/pg_' + self._postgresql.wal_name) and len(wal_filename) == 24:
return wal_filename return wal_filename
def _archive_ready_wals(self): def _archive_ready_wals(self) -> None:
"""Try to archive WALs that have .ready files just in case """Try to archive WALs that have .ready files just in case
archive_mode was not set to 'always' before promote, while archive_mode was not set to 'always' before promote, while
after it the WALs were recycled on the promoted replica. after it the WALs were recycled on the promoted replica.
@@ -352,7 +370,7 @@ class Rewind(object):
# it is the author of archive_command, who is responsible # it is the author of archive_command, who is responsible
# for not overriding the WALs already present in archive # for not overriding the WALs already present in archive
logger.info('Trying to archive %s: %s', wal, cmd) logger.info('Trying to archive %s: %s', wal, cmd)
if self._postgresql.cancellable.call(shlex.split(cmd)) == 0: if self._postgresql.cancellable.call([cmd], shell=True) == 0:
new_name = os.path.join(status_dir, wal + '.done') new_name = os.path.join(status_dir, wal + '.done')
try: try:
shutil.move(old_name, new_name) shutil.move(old_name, new_name)
@@ -361,7 +379,7 @@ class Rewind(object):
else: else:
logger.info('Failed to archive WAL segment %s', wal) logger.info('Failed to archive WAL segment %s', wal)
def _maybe_clean_pg_replslot(self): def _maybe_clean_pg_replslot(self) -> None:
"""Clean pg_replslot directory if pg version is less then 11 """Clean pg_replslot directory if pg version is less then 11
(pg_rewind deletes $PGDATA/pg_replslot content only since pg11).""" (pg_rewind deletes $PGDATA/pg_replslot content only since pg11)."""
if self._postgresql.major_version < 110000: if self._postgresql.major_version < 110000:
@@ -373,32 +391,33 @@ class Rewind(object):
except Exception as e: except Exception as e:
logger.warning('Unable to clean %s: %r', replslot_dir, e) logger.warning('Unable to clean %s: %r', replslot_dir, e)
def pg_rewind(self, r): def pg_rewind(self, r: Dict[str, Any]) -> bool:
# prepare pg_rewind connection # prepare pg_rewind connection
env = self._postgresql.config.write_pgpass(r) env = self._postgresql.config.write_pgpass(r)
env.update(LANG='C', LC_ALL='C', PGOPTIONS='-c statement_timeout=0') env.update(LANG='C', LC_ALL='C', PGOPTIONS='-c statement_timeout=0')
dsn = self._postgresql.config.format_dsn(r, True) dsn = self._postgresql.config.format_dsn(r, True)
logger.info('running pg_rewind from %s', dsn) logger.info('running pg_rewind from %s', dsn)
restore_command = self._postgresql.config.get('recovery_conf', {}).get('restore_command') \ restore_command = (self._postgresql.config.get('recovery_conf') or {}).get('restore_command') \
if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command') if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command')
# Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian # Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian
pg_rewind_can_restore = restore_command and (self._postgresql.major_version >= 150000 or pg_rewind_can_restore = restore_command and (self._postgresql.major_version >= 150000
(self._postgresql.major_version >= 130000 and or (self._postgresql.major_version >= 130000
self._postgresql.config._config_dir == self._postgresql.data_dir)) and self._postgresql.config.config_dir
== self._postgresql.data_dir))
cmd = [self._postgresql.pgcommand('pg_rewind')] cmd = [self._postgresql.pgcommand('pg_rewind')]
if pg_rewind_can_restore: if pg_rewind_can_restore:
cmd.append('--restore-target-wal') cmd.append('--restore-target-wal')
if self._postgresql.major_version >= 150000 and\ if self._postgresql.major_version >= 150000 and\
self._postgresql.config._config_dir != self._postgresql.data_dir: self._postgresql.config.config_dir != self._postgresql.data_dir:
cmd.append('--config-file={0}'.format(self._postgresql.config.postgresql_conf)) cmd.append('--config-file={0}'.format(self._postgresql.config.postgresql_conf))
cmd.extend(['-D', self._postgresql.data_dir, '--source-server', dsn]) cmd.extend(['-D', self._postgresql.data_dir, '--source-server', dsn])
while True: while True:
results = {} results: Dict[str, bytes] = {}
ret = self._postgresql.cancellable.call(cmd, env=env, communicate=results) ret = self._postgresql.cancellable.call(cmd, env=env, communicate=results)
logger.info('pg_rewind exit code=%s', ret) logger.info('pg_rewind exit code=%s', ret)
@@ -421,7 +440,7 @@ class Rewind(object):
logger.info('Failed to fetch WAL segment %s required for pg_rewind', missing_wal) logger.info('Failed to fetch WAL segment %s required for pg_rewind', missing_wal)
return False return False
def execute(self, leader): def execute(self, leader: Union[Leader, RemoteMember]) -> Optional[bool]:
if self._postgresql.is_running() and not self._postgresql.stop(checkpoint=False): if self._postgresql.is_running() and not self._postgresql.stop(checkpoint=False):
return logger.warning('Can not run pg_rewind because postgres is still running') return logger.warning('Can not run pg_rewind because postgres is still running')
@@ -439,7 +458,7 @@ class Rewind(object):
# superuser credentials match rewind_credentials if the latter are not provided or we run 10 or older # superuser credentials match rewind_credentials if the latter are not provided or we run 10 or older
if self._postgresql.config.superuser == self._postgresql.config.rewind_credentials: if self._postgresql.config.superuser == self._postgresql.config.rewind_credentials:
leader_status = self._postgresql.checkpoint( leader_status = self._postgresql.checkpoint(
self._conn_kwargs(leader, self._postgresql.config.superuser)) self._conn_kwargs(leader, self._postgresql.config.superuser))
else: # we run 11+ and have a dedicated pg_rewind user else: # we run 11+ and have a dedicated pg_rewind user
leader_status = self.check_leader_has_run_checkpoint(r) leader_status = self.check_leader_has_run_checkpoint(r)
if leader_status: # we tried to run/check for a checkpoint on the remote leader, but it failed if leader_status: # we tried to run/check for a checkpoint on the remote leader, but it failed
@@ -470,26 +489,26 @@ class Rewind(object):
break break
return False return False
def reset_state(self): def reset_state(self) -> None:
self._state = REWIND_STATUS.INITIAL self._state = REWIND_STATUS.INITIAL
with self._checkpoint_task_lock: with self._checkpoint_task_lock:
self._checkpoint_task = None self._checkpoint_task = None
@property @property
def is_needed(self): def is_needed(self) -> bool:
return self._state in (REWIND_STATUS.CHECK, REWIND_STATUS.NEED) return self._state in (REWIND_STATUS.CHECK, REWIND_STATUS.NEED)
@property @property
def executed(self): def executed(self) -> bool:
return self._state > REWIND_STATUS.NOT_NEED return self._state > REWIND_STATUS.NOT_NEED
@property @property
def failed(self): def failed(self) -> bool:
return self._state == REWIND_STATUS.FAILED return self._state == REWIND_STATUS.FAILED
def read_postmaster_opts(self): def read_postmaster_opts(self) -> Dict[str, str]:
"""returns the list of option names/values from postgres.opts, Empty dict if read failed or no file""" """returns the list of option names/values from postgres.opts, Empty dict if read failed or no file"""
result = {} result: Dict[str, str] = {}
try: try:
with open(os.path.join(self._postgresql.data_dir, 'postmaster.opts')) as f: with open(os.path.join(self._postgresql.data_dir, 'postmaster.opts')) as f:
data = f.read() data = f.read()
@@ -501,7 +520,8 @@ class Rewind(object):
logger.exception('Error when reading postmaster.opts') logger.exception('Error when reading postmaster.opts')
return result return result
def single_user_mode(self, communicate=None, options=None): def single_user_mode(self, communicate: Optional[Dict[str, Any]] = None,
options: Optional[Dict[str, str]] = None) -> Optional[int]:
"""run a given command in a single-user mode. If the command is empty - then just start and stop""" """run a given command in a single-user mode. If the command is empty - then just start and stop"""
cmd = [self._postgresql.pgcommand('postgres'), '--single', '-D', self._postgresql.data_dir] cmd = [self._postgresql.pgcommand('postgres'), '--single', '-D', self._postgresql.data_dir]
for opt, val in sorted((options or {}).items()): for opt, val in sorted((options or {}).items()):
@@ -510,7 +530,7 @@ class Rewind(object):
cmd.append('template1') cmd.append('template1')
return self._postgresql.cancellable.call(cmd, communicate=communicate) return self._postgresql.cancellable.call(cmd, communicate=communicate)
def cleanup_archive_status(self): def cleanup_archive_status(self) -> None:
status_dir = os.path.join(self._postgresql.wal_dir, 'archive_status') status_dir = os.path.join(self._postgresql.wal_dir, 'archive_status')
try: try:
for f in os.listdir(status_dir): for f in os.listdir(status_dir):
@@ -525,7 +545,7 @@ class Rewind(object):
except OSError: except OSError:
logger.exception('Unable to list %s', status_dir) logger.exception('Unable to list %s', status_dir)
def ensure_clean_shutdown(self): def ensure_clean_shutdown(self) -> Optional[bool]:
self._archive_ready_wals() self._archive_ready_wals()
self.cleanup_archive_status() self.cleanup_archive_status()
@@ -533,7 +553,7 @@ class Rewind(object):
opts = self.read_postmaster_opts() opts = self.read_postmaster_opts()
opts.update({'archive_mode': 'on', 'archive_command': 'false'}) opts.update({'archive_mode': 'on', 'archive_command': 'false'})
self._postgresql.config.remove_recovery_conf() self._postgresql.config.remove_recovery_conf()
output = {} output: Dict[str, bytes] = {}
ret = self.single_user_mode(communicate=output, options=opts) ret = self.single_user_mode(communicate=output, options=opts)
if ret != 0: if ret != 0:
logger.error('Crash recovery finished with code=%s', ret) logger.error('Crash recovery finished with code=%s', ret)
+196 -103
View File
@@ -5,36 +5,60 @@ import shutil
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from threading import Condition, Thread from threading import Condition, Thread
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
from .connection import get_connection_cursor from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir from .misc import format_lsn, fsync_dir
from ..dcs import Cluster, Leader
from ..file_perm import pg_perm
from ..psycopg import OperationalError from ..psycopg import OperationalError
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
from psycopg2 import cursor
from . import Postgresql
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def compare_slots(s1, s2, dbid='database'): def compare_slots(s1: Dict[str, Any], s2: Dict[str, Any], dbid: str = 'database') -> bool:
return s1['type'] == s2['type'] and (s1['type'] == 'physical' or """Compare 2 replication slot objects for equality.
s1.get(dbid) == s2.get(dbid) and s1['plugin'] == s2['plugin'])
..note ::
If the first argument is a ``physical`` replication slot then only the `type` of the second slot is compared.
If the first argument is another ``type`` (e.g. ``logical``) then *dbid* and ``plugin`` are compared.
:param s1: First slot dictionary to be compared.
:param s2: Second slot dictionary to be compared.
:param dbid: Optional attribute to be compared when comparing ``logical`` replication slots.
:return: ``True`` if the slot ``type`` of *s1* and *s2* is matches, and the ``type`` of *s1* is ``physical``,
OR the ``types`` match AND the *dbid* and ``plugin`` attributes are equal.
"""
return (s1['type'] == s2['type']
and (s1['type'] == 'physical'
or s1.get(dbid) == s2.get(dbid)
and s1['plugin'] == s2['plugin']))
class SlotsAdvanceThread(Thread): class SlotsAdvanceThread(Thread):
def __init__(self, slots_handler): def __init__(self, slots_handler: 'SlotsHandler') -> None:
super(SlotsAdvanceThread, self).__init__() super(SlotsAdvanceThread, self).__init__()
self.daemon = True self.daemon = True
self._slots_handler = slots_handler self._slots_handler = slots_handler
# _copy_slots and _failed are used to asynchronously give some feedback to the main thread # _copy_slots and _failed are used to asynchronously give some feedback to the main thread
self._copy_slots = [] self._copy_slots: List[str] = []
self._failed = False self._failed = False
# {'dbname1': {'slot1': 100, 'slot2': 100}, 'dbname2': {'slot3': 100}}
self._scheduled = defaultdict(dict) # {'dbname1': {'slot1': 100, 'slot2': 100}, 'dbname2': {'slot3': 100}} self._scheduled: Dict[str, Dict[str, int]] = defaultdict(dict)
self._condition = Condition() # protect self._scheduled from concurrent access and to wakeup the run() method self._condition = Condition() # protect self._scheduled from concurrent access and to wakeup the run() method
self.start() self.start()
def sync_slot(self, cur, database, slot, lsn): def sync_slot(self, cur: Union['cursor', 'Cursor[Any]'], database: str, slot: str, lsn: int) -> None:
failed = copy = False failed = copy = False
try: try:
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", (slot, format_lsn(lsn))) cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", (slot, format_lsn(lsn)))
@@ -55,7 +79,7 @@ class SlotsAdvanceThread(Thread):
if not self._scheduled[database]: if not self._scheduled[database]:
self._scheduled.pop(database) self._scheduled.pop(database)
def sync_slots_in_database(self, database, slots): def sync_slots_in_database(self, database: str, slots: List[str]) -> None:
with self._slots_handler.get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur: with self._slots_handler.get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur:
for slot in slots: for slot in slots:
with self._condition: with self._condition:
@@ -63,7 +87,7 @@ class SlotsAdvanceThread(Thread):
if lsn: if lsn:
self.sync_slot(cur, database, slot, lsn) self.sync_slot(cur, database, slot, lsn)
def sync_slots(self): def sync_slots(self) -> None:
with self._condition: with self._condition:
databases = list(self._scheduled.keys()) databases = list(self._scheduled.keys())
for database in databases: for database in databases:
@@ -75,7 +99,7 @@ class SlotsAdvanceThread(Thread):
except Exception as e: except Exception as e:
logger.error('Failed to advance replication slots in database %s: %r', database, e) logger.error('Failed to advance replication slots in database %s: %r', database, e)
def run(self): def run(self) -> None:
while True: while True:
with self._condition: with self._condition:
if not self._scheduled: if not self._scheduled:
@@ -83,7 +107,7 @@ class SlotsAdvanceThread(Thread):
self.sync_slots() self.sync_slots()
def schedule(self, advance_slots): def schedule(self, advance_slots: Dict[str, Dict[str, int]]) -> Tuple[bool, List[str]]:
with self._condition: with self._condition:
for database, values in advance_slots.items(): for database, values in advance_slots.items():
self._scheduled[database].update(values) self._scheduled[database].update(values)
@@ -94,7 +118,7 @@ class SlotsAdvanceThread(Thread):
return ret return ret
def on_promote(self): def on_promote(self) -> None:
with self._condition: with self._condition:
self._scheduled.clear() self._scheduled.clear()
self._failed = False self._failed = False
@@ -103,22 +127,22 @@ class SlotsAdvanceThread(Thread):
class SlotsHandler(object): class SlotsHandler(object):
def __init__(self, postgresql): def __init__(self, postgresql: 'Postgresql') -> None:
self._postgresql = postgresql self._postgresql = postgresql
self._advance = None self._advance = None
self._replication_slots = {} # already existing replication slots self._replication_slots: Dict[str, Dict[str, Any]] = {} # already existing replication slots
self._unready_logical_slots = {} self._logical_slots_processing_queue: Dict[str, Optional[int]] = {}
self.pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot') self.pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
self.schedule() self.schedule()
def _query(self, sql, *params): def _query(self, sql: str, *params: Any) -> Union['cursor', 'Cursor[Any]']:
return self._postgresql.query(sql, *params, retry=False) return self._postgresql.query(sql, *params, retry=False)
@staticmethod @staticmethod
def _copy_items(src, dst, keys=None): def _copy_items(src: Dict[str, Any], dst: Dict[str, Any], keys: Optional[List[str]] = None) -> None:
dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')}) dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')})
def process_permanent_slots(self, slots): def process_permanent_slots(self, slots: List[Dict[str, Any]]) -> Dict[str, int]:
"""This methods solves three problems at once (I know, it is weird). """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 The cluster_info_query from `Postgresql` is executed every HA loop and returns
@@ -130,11 +154,11 @@ class SlotsHandler(object):
3. Updates the local cache with the fresh catalog_xmin and confirmed_flush_lsn for every known slot. 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. This info is used when performing the check of logical slot readiness on standbys.
""" """
ret = {} ret: Dict[str, int] = {}
slots = {slot['slot_name']: slot for slot in slots or []} slots_dict: Dict[str, Dict[str, Any]] = {slot['slot_name']: slot for slot in slots or []}
if slots: if slots_dict:
for name, value in slots.items(): for name, value in slots_dict.items():
if name in self._replication_slots: if name in self._replication_slots:
if compare_slots(value, self._replication_slots[name], 'datoid'): if compare_slots(value, self._replication_slots[name], 'datoid'):
if value['type'] == 'logical': if value['type'] == 'logical':
@@ -143,15 +167,15 @@ class SlotsHandler(object):
else: else:
self._schedule_load_slots = True self._schedule_load_slots = True
# It could happen that the slots was deleted in the background, we want to detect this case # It could happen that the slot was deleted in the background, we want to detect this case
if any(name not in slots for name in self._replication_slots.keys()): if any(name not in slots_dict for name in self._replication_slots.keys()):
self._schedule_load_slots = True self._schedule_load_slots = True
return ret return ret
def load_replication_slots(self): def load_replication_slots(self) -> None:
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: Dict[str, Dict[str, Any]] = {}
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\ extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
if self._postgresql.major_version >= 100000 else "" if self._postgresql.major_version >= 100000 else ""
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else '' skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
@@ -167,28 +191,33 @@ class SlotsHandler(object):
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: if self._force_readiness_check:
self._unready_logical_slots = {n: None for n, v in replication_slots.items() if v['type'] == 'logical'} self._logical_slots_processing_queue = {n: None for n, v in replication_slots.items()
if v['type'] == 'logical'}
self._force_readiness_check = False self._force_readiness_check = False
def ignore_replication_slot(self, cluster, name): def ignore_replication_slot(self, cluster: Cluster, name: str) -> bool:
slot = self._replication_slots[name] slot = self._replication_slots[name]
for matcher in cluster.config.ignore_slots_matchers: if cluster.config:
if ((matcher.get("name") is None or matcher["name"] == name) for matcher in cluster.config.ignore_slots_matchers:
and all(not matcher.get(a) or matcher[a] == slot.get(a) for a in ('database', 'plugin', 'type'))): if ((matcher.get("name") is None or matcher["name"] == name)
return True and all(not matcher.get(a) or matcher[a] == slot.get(a) for a in ('database', 'plugin', 'type'))):
return True
return self._postgresql.citus_handler.ignore_replication_slot(slot) return self._postgresql.citus_handler.ignore_replication_slot(slot)
def drop_replication_slot(self, name): def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
"""Returns a tuple(active, dropped)""" """Returns a tuple(active, dropped)"""
cursor = self._query(('WITH slots AS (SELECT slot_name, active' + cursor = self._query(('WITH slots AS (SELECT slot_name, active'
' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),' + ' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),'
' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),' + ' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),'
' true AS dropped FROM slots WHERE not active) ' + ' true AS dropped FROM slots WHERE not active) '
'SELECT active, COALESCE(dropped, false) FROM slots' + 'SELECT active, COALESCE(dropped, false) FROM slots'
' FULL OUTER JOIN dropped ON true'), name) ' FULL OUTER JOIN dropped ON true'), name)
return cursor.fetchone() if cursor.rowcount == 1 else (False, False) row = cursor.fetchone()
if not row:
row = (False, False)
return row
def _drop_incorrect_slots(self, cluster, slots, paused): def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
# drop old replication slots which are not presented in desired slots # drop old replication slots which are not presented in desired slots
for name in set(self._replication_slots) - set(slots): for name in set(self._replication_slots) - set(slots):
if not paused and not self.ignore_replication_slot(cluster, name): if not paused and not self.ignore_replication_slot(cluster, name):
@@ -211,13 +240,13 @@ class SlotsHandler(object):
logger.error("Failed to drop replication slot '%s'", name) logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True self._schedule_load_slots = True
def _ensure_physical_slots(self, slots): def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None:
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else '' immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
for name, value in slots.items(): for name, value in slots.items():
if name not in self._replication_slots and value['type'] == 'physical': if name not in self._replication_slots and value['type'] == 'physical':
try: try:
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" + self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})"
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" + " WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
" WHERE slot_type = 'physical' AND slot_name = %s)").format( " WHERE slot_type = 'physical' AND slot_name = %s)").format(
immediately_reserve), name, name) immediately_reserve), name, name)
except Exception: except Exception:
@@ -225,15 +254,15 @@ class SlotsHandler(object):
self._schedule_load_slots = True self._schedule_load_slots = True
@contextmanager @contextmanager
def get_local_connection_cursor(self, **kwargs): def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
conn_kwargs = self._postgresql.config.local_connect_kwargs conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs.update(kwargs) conn_kwargs.update(kwargs)
with get_connection_cursor(**conn_kwargs) as cur: with get_connection_cursor(**conn_kwargs) as cur:
yield cur yield cur
def _ensure_logical_slots_primary(self, slots): def _ensure_logical_slots_primary(self, slots: Dict[str, Any]) -> None:
# Group logical slots to be created by database name # Group logical slots to be created by database name
logical_slots = defaultdict(dict) logical_slots: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(dict)
for name, value in slots.items(): for name, value in slots.items():
if value['type'] == 'logical': if value['type'] == 'logical':
# If the logical already exists, copy some information about it into the original structure # If the logical already exists, copy some information about it into the original structure
@@ -247,8 +276,8 @@ class SlotsHandler(object):
with self.get_local_connection_cursor(dbname=database) as cur: with self.get_local_connection_cursor(dbname=database) as cur:
for name, value in values.items(): for name, value in values.items():
try: try:
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" + cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)"
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" + " WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
" WHERE slot_type = 'logical' AND slot_name = %s)", " WHERE slot_type = 'logical' AND slot_name = %s)",
(name, value['plugin'], name)) (name, value['plugin'], name))
except Exception as e: except Exception as e:
@@ -257,26 +286,27 @@ class SlotsHandler(object):
slots.pop(name) slots.pop(name)
self._schedule_load_slots = True self._schedule_load_slots = True
def schedule_advance_slots(self, slots): def schedule_advance_slots(self, slots: Dict[str, Dict[str, int]]) -> Tuple[bool, List[str]]:
if not self._advance: if not self._advance:
self._advance = SlotsAdvanceThread(self) self._advance = SlotsAdvanceThread(self)
return self._advance.schedule(slots) return self._advance.schedule(slots)
def _ensure_logical_slots_replica(self, cluster, slots): def _ensure_logical_slots_replica(self, cluster: Cluster, slots: Dict[str, Any]) -> List[str]:
advance_slots = defaultdict(dict) # Group logical slots to be advanced by database name # Group logical slots to be advanced by database name
create_slots = [] # And collect logical slots to be created on the replica advance_slots: Dict[str, Dict[str, int]] = defaultdict(dict)
create_slots: List[str] = [] # And collect logical slots to be created on the replica
for name, value in slots.items(): for name, value in slots.items():
if value['type'] == 'logical': if value['type'] == 'logical':
# If the logical already exists, copy some information about it into the original structure # If the logical already exists, copy some information about it into the original structure
if self._replication_slots.get(name, {}).get('datoid'): if self._replication_slots.get(name, {}).get('datoid'):
self._copy_items(self._replication_slots[name], value) self._copy_items(self._replication_slots[name], value)
if name in cluster.slots: if cluster.slots and name in cluster.slots:
try: # Skip slots that doesn't need to be advanced try: # Skip slots that doesn't need to be advanced
if value['confirmed_flush_lsn'] < int(cluster.slots[name]): if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
advance_slots[value['database']][name] = int(cluster.slots[name]) advance_slots[value['database']][name] = int(cluster.slots[name])
except Exception as e: except Exception as e:
logger.error('Failed to parse "%s": %r', cluster.slots[name], 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 elif cluster.slots and name in cluster.slots: # We want to copy only slots with feedback in a DCS
create_slots.append(name) create_slots.append(name)
error, copy_slots = self.schedule_advance_slots(advance_slots) error, copy_slots = self.schedule_advance_slots(advance_slots)
@@ -284,8 +314,9 @@ class SlotsHandler(object):
self._schedule_load_slots = True self._schedule_load_slots = True
return create_slots + copy_slots return create_slots + copy_slots
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None, paused=False): def sync_replication_slots(self, cluster: Cluster, nofailover: bool,
ret = None replicatefrom: Optional[str] = None, paused: bool = False) -> List[str]:
ret = []
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()
@@ -298,10 +329,10 @@ class SlotsHandler(object):
self._ensure_physical_slots(slots) self._ensure_physical_slots(slots)
if self._postgresql.is_leader(): if self._postgresql.is_leader():
self._unready_logical_slots.clear() self._logical_slots_processing_queue.clear()
self._ensure_logical_slots_primary(slots) self._ensure_logical_slots_primary(slots)
elif cluster.slots and slots: elif cluster.slots and slots:
self.check_logical_slots_readiness(cluster, nofailover, replicatefrom) self.check_logical_slots_readiness(cluster, replicatefrom)
ret = self._ensure_logical_slots_replica(cluster, slots) ret = self._ensure_logical_slots_replica(cluster, slots)
@@ -312,56 +343,114 @@ class SlotsHandler(object):
return ret return ret
@contextmanager @contextmanager
def _get_leader_connection_cursor(self, leader): def _get_leader_connection_cursor(self, leader: Leader) -> Iterator[Union['cursor', 'Cursor[Any]']]:
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials) conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
conn_kwargs['dbname'] = self._postgresql.database conn_kwargs['dbname'] = self._postgresql.database
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur: with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
yield cur yield cur
def check_logical_slots_readiness(self, cluster, nofailover, replicatefrom): def check_logical_slots_readiness(self, cluster: Cluster, replicatefrom: Optional[str]) -> bool:
if self._unready_logical_slots: """Determine whether all known logical slots are synchronised from the leader.
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
2) using previously stored list of "unready" logical slots, those which have yet to be checked hence have no
stored slot attributes,
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
:param cluster: object containing stateful information for the cluster.
:param replicatefrom: name of the member that should be used to replicate from.
:returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise.
"""
catalog_xmin = None
if self._logical_slots_processing_queue and cluster.leader:
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom) slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
try: try:
with self._get_leader_connection_cursor(cluster.leader) as cur: with self._get_leader_connection_cursor(cluster.leader) as cur:
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()" cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)", " WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)",
([n for n, v in self._unready_logical_slots.items() if v is None] + [slot_name],)) ([n for n, v in self._logical_slots_processing_queue.items()
if v is None] + [slot_name],))
slots = {row[0]: row[1] for row in cur} slots = {row[0]: row[1] for row in cur}
if slot_name not in slots: if slot_name not in slots:
return logger.warning('Physical slot %s does not exist on the primary', slot_name) logger.warning('Physical slot %s does not exist on the primary', slot_name)
return False
catalog_xmin = slots.pop(slot_name) catalog_xmin = slots.pop(slot_name)
except Exception as e: except Exception as e:
return logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e) logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
# Remember catalog_xmin of logical slots on the primary when catalog_xmin of return False
# the physical slot became valid. Logical slots on replica will be safe to use after
# promote when catalog_xmin of the physical slot overtakes these values.
if catalog_xmin:
for name, value in slots.items():
self._unready_logical_slots[name] = value
else: # Replica isn't streaming or the hot_standby_feedback isn't enabled
try:
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
if not cur.fetchone()[0]:
logger.error('Logical slot failover requires "hot_standby_feedback".'
' Please check postgresql.auto.conf')
except Exception as e:
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
return # since `catalog_xmin` isn't valid further checks don't make any sense
for name in list(self._unready_logical_slots): if not self._update_pending_logical_slot_primary(slots, catalog_xmin):
value = self._replication_slots.get(name) return False # since `catalog_xmin` isn't valid further checks don't make any sense
# The logical slot on a replica is safe to use when the physical replica slot on the primary:
# 1. has a nonzero/non-null catalog_xmin self._ready_logical_slots(catalog_xmin)
# 2. has a catalog_xmin that is not newer (greater) than the catalog_xmin of any slot on the standby return True
# 3. overtook the catalog_xmin of remembered values of logical slots on the primary.
if not value or self._unready_logical_slots[name] <= catalog_xmin <= value['catalog_xmin']: def _update_pending_logical_slot_primary(self, slots: Dict[str, Any], catalog_xmin: Optional[int] = None) -> bool:
del self._unready_logical_slots[name] """Store pending logical slot information for ``catalog_xmin`` on the primary.
if value:
Remember ``catalog_xmin`` of logical slots on the primary when ``catalog_xmin`` of the physical slot became
valid. Logical slots on replica will be safe to use after promote when ``catalog_xmin`` of the physical slot
overtakes these values.
:param slots: dictionary of slot information from the primary
:param catalog_xmin: ``catalog_xmin`` of the physical slot used by this replica to stream changes from primary.
:returns: ``False`` if any issue was faced while processing, ``True`` otherwise.
"""
if catalog_xmin is not None:
for name, value in slots.items():
self._logical_slots_processing_queue[name] = value
return True
# Replica isn't streaming or the hot_standby_feedback isn't enabled
try:
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
row = cur.fetchone()
if row and not row[0]:
logger.error('Logical slot failover requires "hot_standby_feedback".'
' Please check postgresql.auto.conf')
except Exception as e:
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
return False
def _ready_logical_slots(self, primary_physical_catalog_xmin: Optional[int] = None) -> None:
"""Ready logical slots by comparing primary physical slot ``catalog_xmin`` to logical ``catalog_xmin``.
The logical slot on a replica is safe to use when the physical replica slot on the primary:
1. has a nonzero/non-null ``catalog_xmin`` represented by ``primary_physical_xmin``.
2. has a ``catalog_xmin`` that is not newer (greater) than the ``catalog_xmin`` of any slot on the standby
3. overtook the ``catalog_xmin`` of remembered values of logical slots on the primary.
:param primary_physical_catalog_xmin: is the value retrieved from ``pg_catalog.pg_get_replication_slots()`` for
the physical replication slot on the primary.
"""
# Make a copy of processing queue keys as a list as the queue dictionary is modified inside the loop.
for name in list(self._logical_slots_processing_queue):
primary_logical_catalog_xmin = self._logical_slots_processing_queue[name]
standby_logical_slot = self._replication_slots.get(name, {})
standby_logical_catalog_xmin = standby_logical_slot.get('catalog_xmin', 0)
if TYPE_CHECKING: # pragma: no cover
assert primary_logical_catalog_xmin is not None
if (
not standby_logical_slot
or primary_physical_catalog_xmin is not None
and primary_logical_catalog_xmin <= primary_physical_catalog_xmin <= standby_logical_catalog_xmin
):
del self._logical_slots_processing_queue[name]
if standby_logical_slot:
logger.info('Logical slot %s is safe to be used after a failover', name) logger.info('Logical slot %s is safe to be used after a failover', name)
def copy_logical_slots(self, cluster, create_slots): def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None:
leader = cluster.leader leader = cluster.leader
if not leader:
return
slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version) slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version)
copy_slots: Dict[str, Dict[str, Any]] = {}
with self._get_leader_connection_cursor(leader) as cur: with self._get_leader_connection_cursor(leader) as cur:
try: try:
cur.execute("SELECT slot_name, slot_type, datname, plugin, catalog_xmin, " cur.execute("SELECT slot_name, slot_type, datname, plugin, catalog_xmin, "
@@ -370,48 +459,52 @@ class SlotsHandler(object):
" FROM pg_catalog.pg_get_replication_slots() JOIN pg_catalog.pg_database ON datoid = oid" " FROM pg_catalog.pg_get_replication_slots() JOIN pg_catalog.pg_database ON datoid = oid"
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)", (create_slots,)) " WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)", (create_slots,))
create_slots = {}
for r in cur: for r in cur:
if r[0] in slots: # slot_name is defined in the global configuration if r[0] in slots: # slot_name is defined in the global configuration
slot = {'type': r[1], 'database': r[2], 'plugin': r[3], slot = {'type': r[1], 'database': r[2], 'plugin': r[3],
'catalog_xmin': r[4], 'confirmed_flush_lsn': r[5], 'data': r[6]} 'catalog_xmin': r[4], 'confirmed_flush_lsn': r[5], 'data': r[6]}
if compare_slots(slot, slots[r[0]]): if compare_slots(slot, slots[r[0]]):
create_slots[r[0]] = slot copy_slots[r[0]] = slot
else: else:
logger.warning('Will not copy the logical slot "%s" due to the configuration mismatch: ' + logger.warning('Will not copy the logical slot "%s" due to the configuration mismatch: '
'configuration=%s, slot on the primary=%s', r[0], slots[r[0]], slot) 'configuration=%s, slot on the primary=%s', r[0], slots[r[0]], slot)
except Exception as e: except Exception as e:
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e) logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
if isinstance(create_slots, dict) and create_slots and self._postgresql.stop(): if copy_slots and self._postgresql.stop():
for name, value in create_slots.items(): pg_perm.set_permissions_from_data_directory(self._postgresql.data_dir)
slot_dir = os.path.join(self._postgresql.slots_handler.pg_replslot_dir, name) for name, value in copy_slots.items():
slot_dir = os.path.join(self.pg_replslot_dir, name)
slot_tmp_dir = slot_dir + '.tmp' slot_tmp_dir = slot_dir + '.tmp'
if os.path.exists(slot_tmp_dir): if os.path.exists(slot_tmp_dir):
shutil.rmtree(slot_tmp_dir) shutil.rmtree(slot_tmp_dir)
os.makedirs(slot_tmp_dir) os.makedirs(slot_tmp_dir)
os.chmod(slot_tmp_dir, pg_perm.dir_create_mode)
fsync_dir(slot_tmp_dir) fsync_dir(slot_tmp_dir)
with open(os.path.join(slot_tmp_dir, 'state'), 'wb') as f: slot_filename = os.path.join(slot_tmp_dir, 'state')
with open(slot_filename, 'wb') as f:
os.chmod(slot_filename, pg_perm.file_create_mode)
f.write(value['data']) f.write(value['data'])
f.flush() f.flush()
os.fsync(f.fileno()) os.fsync(f.fileno())
if os.path.exists(slot_dir): if os.path.exists(slot_dir):
shutil.rmtree(slot_dir) shutil.rmtree(slot_dir)
os.rename(slot_tmp_dir, slot_dir) os.rename(slot_tmp_dir, slot_dir)
os.chmod(slot_dir, pg_perm.dir_create_mode)
fsync_dir(slot_dir) fsync_dir(slot_dir)
self._unready_logical_slots[name] = None self._logical_slots_processing_queue[name] = None
fsync_dir(self._postgresql.slots_handler.pg_replslot_dir) fsync_dir(self.pg_replslot_dir)
self._postgresql.start() self._postgresql.start()
def schedule(self, value=None): def schedule(self, value: Optional[bool] = None) -> 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 = self._force_readiness_check = value self._schedule_load_slots = self._force_readiness_check = value
def on_promote(self): def on_promote(self) -> None:
if self._advance: if self._advance:
self._advance.on_promote() self._advance.on_promote()
if self._unready_logical_slots: if self._logical_slots_processing_queue:
logger.warning('Logical replication slots that might be unsafe to use after promote: %s', logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
set(self._unready_logical_slots)) set(self._logical_slots_processing_queue))
+197 -104
View File
@@ -3,9 +3,13 @@ import re
import time import time
from copy import deepcopy from copy import deepcopy
from typing import Collection, List, NamedTuple, Tuple, TYPE_CHECKING
from .validator import CaseInsensitiveDict from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import Cluster
from ..psycopg import quote_ident as _quote_ident from ..psycopg import quote_ident as _quote_ident
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,45 +27,59 @@ SYNC_REP_PARSER_RE = re.compile(r"""
| (?P<parenend> \) ) | (?P<parenend> \) )
| (?P<JUNK> . ) | (?P<JUNK> . )
""", re.X) """, re.X)
_EMPTY_SSN = {'type': 'off', 'num': 0, 'members': CaseInsensitiveDict({})}
def quote_ident(value): def quote_ident(value: str) -> str:
"""Very simplified version of quote_ident""" """Very simplified version of `psycopg` :func:`quote_ident` function."""
return value if SYNC_STANDBY_NAME_RE.match(value) else _quote_ident(value) return value if SYNC_STANDBY_NAME_RE.match(value) else _quote_ident(value)
def parse_sync_standby_names(value): class _SSN(NamedTuple):
"""Parse postgresql synchronous_standby_names to constituent parts. """class representing "synchronous_standby_names" value after parsing.
Returns dict with the following keys:
* type: 'quorum'|'priority'
* num: int
* members: CaseInsensitiveDict, with names as keys
* has_star: bool - Present if true
If the configuration value can not be parsed, raises a ValueError.
>>> parse_sync_standby_names('')['type'] :ivar sync_type: possible values: 'off', 'priority', 'quorum'
:ivar has_star: is set to `True` if "synchronous_standby_names" contains '*'
:ivar num: how many nodes are required to be synchronous
:ivar members: collection of standby names listed in "synchronous_standby_names"
"""
sync_type: str
has_star: bool
num: int
members: CaseInsensitiveSet
_EMPTY_SSN = _SSN('off', False, 0, CaseInsensitiveSet())
def parse_sync_standby_names(value: str) -> _SSN:
"""Parse postgresql synchronous_standby_names to constituent parts.
:param value: the value of `synchronous_standby_names`
:returns: :class:`_SSN` object
:raises `ValueError`: if the configuration value can not be parsed
>>> parse_sync_standby_names('').sync_type
'off' 'off'
>>> parse_sync_standby_names('FiRsT')['type'] >>> parse_sync_standby_names('FiRsT').sync_type
'priority' 'priority'
>>> parse_sync_standby_names('FiRsT')['members'] >>> 'first' in parse_sync_standby_names('FiRsT').members
{'FiRsT': True}
>>> parse_sync_standby_names('"1"')['members']
{'1': True}
>>> parse_sync_standby_names(' a , b ')['members']
{'a': True, 'b': True}
>>> parse_sync_standby_names(' a , b ')['num']
1
>>> parse_sync_standby_names('ANY 4("a",*,b)')['has_star']
True True
>>> parse_sync_standby_names('ANY 4("a",*,b)')['num'] >>> set(parse_sync_standby_names('"1"').members)
{'1'}
>>> parse_sync_standby_names(' a , b ').members == {'a', 'b'}
True
>>> parse_sync_standby_names(' a , b ').num
1
>>> parse_sync_standby_names('ANY 4("a",*,b)').has_star
True
>>> parse_sync_standby_names('ANY 4("a",*,b)').num
4 4
>>> parse_sync_standby_names('1') # doctest: +IGNORE_EXCEPTION_DETAIL >>> parse_sync_standby_names('1') # doctest: +IGNORE_EXCEPTION_DETAIL
@@ -96,18 +114,24 @@ def parse_sync_standby_names(value):
return deepcopy(_EMPTY_SSN) return deepcopy(_EMPTY_SSN)
if [t[0] for t in tokens[0:3]] == ['any', 'num', 'parenstart'] and tokens[-1][0] == 'parenend': if [t[0] for t in tokens[0:3]] == ['any', 'num', 'parenstart'] and tokens[-1][0] == 'parenend':
result = {'type': 'quorum', 'num': int(tokens[1][1])} sync_type = 'quorum'
num = int(tokens[1][1])
synclist = tokens[3:-1] synclist = tokens[3:-1]
elif [t[0] for t in tokens[0:3]] == ['first', 'num', 'parenstart'] and tokens[-1][0] == 'parenend': elif [t[0] for t in tokens[0:3]] == ['first', 'num', 'parenstart'] and tokens[-1][0] == 'parenend':
result = {'type': 'priority', 'num': int(tokens[1][1])} sync_type = 'priority'
num = int(tokens[1][1])
synclist = tokens[3:-1] synclist = tokens[3:-1]
elif [t[0] for t in tokens[0:2]] == ['num', 'parenstart'] and tokens[-1][0] == 'parenend': elif [t[0] for t in tokens[0:2]] == ['num', 'parenstart'] and tokens[-1][0] == 'parenend':
result = {'type': 'priority', 'num': int(tokens[0][1])} sync_type = 'priority'
num = int(tokens[0][1])
synclist = tokens[2:-1] synclist = tokens[2:-1]
else: else:
result = {'type': 'priority', 'num': 1} sync_type = 'priority'
num = 1
synclist = tokens synclist = tokens
result['members'] = CaseInsensitiveDict({})
has_star = False
members = CaseInsensitiveSet()
for i, (a_type, a_value, a_pos) in enumerate(synclist): for i, (a_type, a_value, a_pos) in enumerate(synclist):
if i % 2 == 1: # odd elements are supposed to be commas if i % 2 == 1: # odd elements are supposed to be commas
if len(synclist) == i + 1: # except the last token if len(synclist) == i + 1: # except the last token
@@ -117,16 +141,82 @@ def parse_sync_standby_names(value):
raise ValueError("Unparseable synchronous_standby_names value %r: ""Got token %s %r while" raise ValueError("Unparseable synchronous_standby_names value %r: ""Got token %s %r while"
" expecting comma at %d" % (value, a_type, a_value, a_pos)) " expecting comma at %d" % (value, a_type, a_value, a_pos))
elif a_type in {'ident', 'first', 'any'}: elif a_type in {'ident', 'first', 'any'}:
result['members'][a_value] = True members.add(a_value)
elif a_type == 'star': elif a_type == 'star':
result['members'][a_value] = True members.add(a_value)
result['has_star'] = True has_star = True
elif a_type == 'dquot': elif a_type == 'dquot':
result['members'][a_value[1:-1].replace('""', '"')] = True members.add(a_value[1:-1].replace('""', '"'))
else: else:
raise ValueError("Unparseable synchronous_standby_names value %r: Unexpected token %s %r at %d" % raise ValueError("Unparseable synchronous_standby_names value %r: Unexpected token %s %r at %d" %
(value, a_type, a_value, a_pos)) (value, a_type, a_value, a_pos))
return result return _SSN(sync_type, has_star, num, members)
class _Replica(NamedTuple):
"""Class representing a single replica that is eligible to be synchronous.
Attributes are taken from ``pg_stat_replication`` view and respective ``Cluster.members``.
:ivar pid: PID of walsender process.
:ivar application_name: matches with the ``Member.name``.
:ivar sync_state: possible values are: ``async``, ``potential``, ``quorum``, and ``sync``.
:ivar lsn: ``write_lsn``, ``flush_lsn``, or ``replay_lsn``, depending on the value of ``synchronous_commit`` GUC.
:ivar nofailover: whether the corresponding member has ``nofailover`` tag set to ``True``.
"""
pid: int
application_name: str
sync_state: str
lsn: int
nofailover: bool
class _ReplicaList(List[_Replica]):
"""A collection of :class:``_Replica`` objects.
Values are reverse ordered by ``_Replica.sync_state`` and ``_Replica.lsn``.
That is, first there will be replicas that have ``sync_state`` == ``sync``, even if they are not
the most up-to-date in term of write/flush/replay LSN. It helps to keep the result of chosing new
synchronous nodes consistent in case if a synchronous standby member is slowed down OR async node
is receiving changes faster than the sync member. Such cases would trigger sync standby member
swapping, but only if lag on this member is exceeding a threshold (``maximum_lag_on_syncnode``).
:ivar max_lsn: maximum value of ``_Replica.lsn`` among all values. In case if there is just one
element in the list we take value of ``pg_current_wal_lsn()``.
"""
def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
"""Create :class:``_ReplicaList`` object.
:param postgresql: reference to :class:``Postgresql`` object.
:param cluster: currently known cluster state from DCS.
"""
super().__init__()
# We want to prioritize candidates based on `write_lsn``, ``flush_lsn``, or ``replay_lsn``.
# Which column exactly to pick depends on the values of ``synchronous_commit`` GUC.
sort_col = {
'remote_apply': 'replay',
'remote_write': 'write'
}.get(postgresql.synchronous_commit(), 'flush') + '_lsn'
members = CaseInsensitiveDict({m.name: m for m in cluster.members})
for row in postgresql.pg_stat_replication():
member = members.get(row['application_name'])
# We want to consider only rows from ``pg_stat_replication` that:
# 1. are known to be streaming (write/flush/replay LSN are not NULL).
# 2. can be mapped to a ``Member`` of the ``Cluster``:
# a. ``Member`` doesn't have ``nosync`` tag set;
# b. PostgreSQL on the member is known to be running and accepting client connections.
if member and row[sort_col] is not None and member.is_running and not member.tags.get('nosync', False):
self.append(_Replica(row['pid'], row['application_name'],
row['sync_state'], row[sort_col], bool(member.nofailover)))
# Prefer replicas that are in state ``sync`` and with higher values of ``write``/``flush``/``replay`` LSN.
self.sort(key=lambda r: (r.sync_state, r.lsn), reverse=True)
self.max_lsn = max(self, key=lambda x: x.lsn).lsn if len(self) > 1 else postgresql.last_operation()
class SyncHandler(object): class SyncHandler(object):
@@ -137,7 +227,7 @@ class SyncHandler(object):
and the `current_state()` method will count newly added names as "sync" only when and the `current_state()` method will count newly added names as "sync" only when
they reached memorized LSN and also reported as "sync" by `pg_stat_replication`""" they reached memorized LSN and also reported as "sync" by `pg_stat_replication`"""
def __init__(self, postgresql): def __init__(self, postgresql: 'Postgresql') -> None:
self._postgresql = postgresql self._postgresql = postgresql
self._synchronous_standby_names = '' # last known value of synchronous_standby_names self._synchronous_standby_names = '' # last known value of synchronous_standby_names
self._ssn_data = deepcopy(_EMPTY_SSN) self._ssn_data = deepcopy(_EMPTY_SSN)
@@ -145,12 +235,15 @@ class SyncHandler(object):
# "sync" replication connections, that were verified to reach self._primary_flush_lsn at some point # "sync" replication connections, that were verified to reach self._primary_flush_lsn at some point
self._ready_replicas = CaseInsensitiveDict({}) # keys: member names, values: connection pids self._ready_replicas = CaseInsensitiveDict({}) # keys: member names, values: connection pids
def _handle_synchronous_standby_names_change(self): def _handle_synchronous_standby_names_change(self) -> None:
"""If synchronous_standby_names has changed we need to check that newly added replicas """Handles changes of "synchronous_standby_names" GUC.
have reached self._primary_flush_lsn. Only after that they could be counted as sync."""
If "synchronous_standby_names" was changed, we need to check that newly added replicas have
reached `self._primary_flush_lsn`. Only after that they could be counted as synchronous.
"""
synchronous_standby_names = self._postgresql.synchronous_standby_names() synchronous_standby_names = self._postgresql.synchronous_standby_names()
if synchronous_standby_names == self._synchronous_standby_names: if synchronous_standby_names == self._synchronous_standby_names:
return False return
self._synchronous_standby_names = synchronous_standby_names self._synchronous_standby_names = synchronous_standby_names
try: try:
@@ -161,91 +254,91 @@ class SyncHandler(object):
# Invalidate cache of "sync" connections # Invalidate cache of "sync" connections
for app_name in list(self._ready_replicas.keys()): for app_name in list(self._ready_replicas.keys()):
if app_name not in self._ssn_data['members']: if app_name not in self._ssn_data.members:
del self._ready_replicas[app_name] del self._ready_replicas[app_name]
# Newly connected replicas will be counted as sync only when reached self._primary_flush_lsn # Newly connected replicas will be counted as sync only when reached self._primary_flush_lsn
self._primary_flush_lsn = self._postgresql.last_operation() self._primary_flush_lsn = self._postgresql.last_operation()
self._postgresql.query('SELECT pg_catalog.txid_current()') # Ensure some WAL traffic to move replication # Ensure some WAL traffic to move replication
self._postgresql.query("""DO $$
BEGIN
SET local synchronous_commit = 'off';
PERFORM * FROM pg_catalog.txid_current();
END;$$""")
self._postgresql.reset_cluster_info_state(None) # Reset internal cache to query fresh values self._postgresql.reset_cluster_info_state(None) # Reset internal cache to query fresh values
def current_state(self, cluster, sync_node_count=1, sync_node_maxlag=-1): def _process_replica_readiness(self, cluster: Cluster, replica_list: _ReplicaList) -> None:
"""Flags replicas as truly "synchronous" when they have caught up with ``_primary_flush_lsn``.
:param cluster: current cluster topology from DCS
:param replica_list: collection of replicas that we want to evaluate.
"""
for replica in replica_list:
# if standby name is listed in the /sync key we can count it as synchronous, otherwise
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
if replica.application_name not in self._ready_replicas\
and replica.application_name in self._ssn_data.members\
and (cluster.sync.matches(replica.application_name)
or replica.sync_state == 'sync' and replica.lsn >= self._primary_flush_lsn):
self._ready_replicas[replica.application_name] = replica.pid
def current_state(self, cluster: Cluster) -> Tuple[CaseInsensitiveSet, CaseInsensitiveSet]:
"""Finds best candidates to be the synchronous standbys. """Finds best candidates to be the synchronous standbys.
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 in case
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
will not swap. Please note that it will not also swap sync standbys in case where all replicas are hung.
:returns: tuple of candidates list and synchronous standby list.""" Standbys are selected based on values from the global configuration:
- `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
standbys during high loads. Any value less or equal of 0 keeps the behavior backward compatible.
Please note that it will not also swap sync standbys in case where all replicas are hung.
- `synchronous_node_count`: controlls how many nodes should be set as synchronous.
:returns: tuple of candidates :class:`CaseInsensitiveSet` and synchronous standbys :class:`CaseInsensitiveSet`.
"""
self._handle_synchronous_standby_names_change() self._handle_synchronous_standby_names_change()
# Pick candidates based on who has higher replay/remote_write/flush lsn. replica_list = _ReplicaList(self._postgresql, cluster)
sort_col = { self._process_replica_readiness(cluster, replica_list)
'remote_apply': 'replay',
'remote_write': 'write'
}.get(self._postgresql.synchronous_commit(), 'flush') + '_lsn'
pg_stat_replication = [(r['pid'], r['application_name'], r['sync_state'], r[sort_col]) if TYPE_CHECKING: # pragma: no cover
for r in self._postgresql.pg_stat_replication() assert self._postgresql.global_config is not None
if r[sort_col] is not None] sync_node_count = self._postgresql.global_config.synchronous_node_count\
if self._postgresql.supports_multiple_sync else 1
sync_node_maxlag = self._postgresql.global_config.maximum_lag_on_syncnode
members = CaseInsensitiveDict({m.name: m for m in cluster.members}) candidates = CaseInsensitiveSet()
replica_list = [] sync_nodes = CaseInsensitiveSet()
# pg_stat_replication.sync_state has 4 possible states - async, potential, quorum, sync.
# That is, alphabetically they are in the reversed order of priority.
# Since we are doing reversed sort on (sync_state, lsn) tuples, it helps to keep the result
# consistent in case if a synchronous standby member is slowed down OR async node receiving
# changes faster than the sync member (very rare but possible).
# Such cases would trigger sync standby member swapping, but only if lag on a sync node exceeding a threshold.
for pid, app_name, sync_state, replica_lsn in sorted(pg_stat_replication, key=lambda r: r[2:4], reverse=True):
member = members.get(app_name)
if member and member.is_running and not member.tags.get('nosync', False):
replica_list.append((pid, member.name, sync_state, replica_lsn, bool(member.nofailover)))
max_lsn = max(replica_list, key=lambda x: x[3])[3]\
if len(replica_list) > 1 else self._postgresql.last_operation()
if self._postgresql.major_version < 90600:
sync_node_count = 1
candidates = []
sync_nodes = []
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable. # Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
for pid, app_name, sync_state, replica_lsn, _ in sorted(replica_list, key=lambda x: x[4]): for replica in sorted(replica_list, key=lambda x: x.nofailover):
# if standby name is listed in the /sync key we can count it as synchronous, otherwice if sync_node_maxlag <= 0 or replica_list.max_lsn - replica.lsn <= sync_node_maxlag:
# ig becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up candidates.add(replica.application_name)
if app_name not in self._ready_replicas and app_name in self._ssn_data['members'] and\ if replica.sync_state == 'sync' and replica.application_name in self._ready_replicas:
(cluster.sync and app_name in cluster.sync.members or sync_nodes.add(replica.application_name)
sync_state == 'sync' and replica_lsn >= self._primary_flush_lsn):
self._ready_replicas[app_name] = pid
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
candidates.append(app_name)
if sync_state == 'sync' and app_name in self._ready_replicas:
sync_nodes.append(app_name)
if len(candidates) >= sync_node_count: if len(candidates) >= sync_node_count:
break break
return candidates, sync_nodes return candidates, sync_nodes
def set_synchronous_standby_names(self, value): def set_synchronous_standby_names(self, sync: Collection[str]) -> None:
"""Constructs and sets `synchronous_standby_names` value. """Constructs and sets "synchronous_standby_names" GUC value.
:param value: list[str] - the list of wanted sync members""" :param sync: set of nodes to sync to
if value and value != ['*']: """
value = [quote_ident(x) for x in value] has_asterisk = '*' in sync
if has_asterisk:
if self._postgresql.major_version >= 90600 and len(value) > 1: sync = ['*']
sync_param = '{0} ({1})'.format(len(value), ','.join(value))
else: else:
sync_param = next(iter(value), None) sync = [quote_ident(x) for x in sync]
if not (self._postgresql.config.set_synchronous_standby_names(sync_param) and if self._postgresql.supports_multiple_sync and len(sync) > 1:
self._postgresql.state == 'running' and self._postgresql.is_leader()) or value == ['*']: sync_param = '{0} ({1})'.format(len(sync), ','.join(sync))
else:
sync_param = next(iter(sync), None)
if not (self._postgresql.config.set_synchronous_standby_names(sync_param)
and self._postgresql.state == 'running' and self._postgresql.is_leader()) or has_asterisk:
return return
time.sleep(0.1) # Usualy it takes 1ms to reload postgresql.conf, but we will give it 100ms time.sleep(0.1) # Usualy it takes 1ms to reload postgresql.conf, but we will give it 100ms
@@ -253,6 +346,6 @@ class SyncHandler(object):
# Reset internal cache to query fresh values # Reset internal cache to query fresh values
self._postgresql.reset_cluster_info_state(None) self._postgresql.reset_cluster_info_state(None)
# timeline == 0 -- indicates that this is the replica, shoudn't ever happen # timeline == 0 -- indicates that this is the replica
if self._postgresql.get_primary_timeline() > 0: if self._postgresql.get_primary_timeline() > 0:
self._handle_synchronous_standby_names_change() self._handle_synchronous_standby_names_change()
+423 -463
View File
@@ -1,46 +1,87 @@
import abc import abc
from copy import deepcopy
import logging import logging
import os
import yaml
from collections import namedtuple from typing import Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Type, Union
from urllib3.response import HTTPHeaderDict
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..exceptions import PatroniException
from ..utils import parse_bool, parse_int, parse_real from ..utils import parse_bool, parse_int, parse_real
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CaseInsensitiveDict(HTTPHeaderDict): class _Transformable(abc.ABC):
def add(self, key, val): def __init__(self, version_from: int, version_till: Optional[int] = None) -> None:
self[key] = val self.__version_from = version_from
self.__version_till = version_till
def __getitem__(self, key): @classmethod
return self._container[key.lower()][1] def get_subclasses(cls) -> Iterator[Type['_Transformable']]:
"""Recursively get all subclasses of :class:`_Transformable`.
def __repr__(self): :yields: each subclass of :class:`_Transformable`.
return str(dict(self.items())) """
for subclass in cls.__subclasses__():
yield from subclass.get_subclasses()
yield subclass
def copy(self): @property
return CaseInsensitiveDict(self._container.values()) def version_from(self) -> int:
return self.__version_from
@property
def version_till(self) -> Optional[int]:
return self.__version_till
@abc.abstractmethod
def transform(self, name: str, value: Any) -> Optional[Any]:
"""Verify that provided value is valid.
:param name: GUC's name
:param value: GUC's value
:returns: the value (sometimes clamped) or ``None`` if the value isn't valid
"""
class Bool(namedtuple('Bool', 'version_from,version_till')): class Bool(_Transformable):
@staticmethod def transform(self, name: str, value: Any) -> Optional[Any]:
def transform(name, value):
if parse_bool(value) is not None: if parse_bool(value) is not None:
return value return value
logger.warning('Removing bool parameter=%s from the config due to the invalid value=%s', name, value) logger.warning('Removing bool parameter=%s from the config due to the invalid value=%s', name, value)
class Number(abc.ABC, namedtuple('Number', 'version_from,version_till,min_val,max_val,unit')): class Number(_Transformable):
def __init__(self, *, version_from: int, version_till: Optional[int] = None, min_val: Union[int, float],
max_val: Union[int, float], unit: Optional[str] = None) -> None:
super(Number, self).__init__(version_from, version_till)
self.__min_val = min_val
self.__max_val = max_val
self.__unit = unit
@property
def min_val(self) -> Union[int, float]:
return self.__min_val
@property
def max_val(self) -> Union[int, float]:
return self.__max_val
@property
def unit(self) -> Optional[str]:
return self.__unit
@staticmethod @staticmethod
@abc.abstractmethod @abc.abstractmethod
def parse(value, unit): def parse(value: Any, unit: Optional[str]) -> Optional[Any]:
"""parse value""" """Convert provided value to unit."""
def transform(self, name, value): def transform(self, name: str, value: Any) -> Union[int, float, None]:
num_value = self.parse(value, self.unit) num_value = self.parse(value, self.unit)
if num_value is not None: if num_value is not None:
if num_value < self.min_val: if num_value < self.min_val:
@@ -59,20 +100,29 @@ class Number(abc.ABC, namedtuple('Number', 'version_from,version_till,min_val,ma
class Integer(Number): class Integer(Number):
@staticmethod @staticmethod
def parse(value, unit): def parse(value: Any, unit: Optional[str]) -> Optional[int]:
return parse_int(value, unit) return parse_int(value, unit)
class Real(Number): class Real(Number):
@staticmethod @staticmethod
def parse(value, unit): def parse(value: Any, unit: Optional[str]) -> Optional[float]:
return parse_real(value, unit) return parse_real(value, unit)
class Enum(namedtuple('Enum', 'version_from,version_till,possible_values')): class Enum(_Transformable):
def transform(self, name, value): def __init__(self, *, version_from: int, version_till: Optional[int] = None,
possible_values: Tuple[str, ...]) -> None:
super(Enum, self).__init__(version_from, version_till)
self.__possible_values = possible_values
@property
def possible_values(self) -> Tuple[str, ...]:
return self.__possible_values
def transform(self, name: str, value: Optional[Any]) -> Optional[Any]:
if str(value).lower() in self.possible_values: if str(value).lower() in self.possible_values:
return value return value
logger.warning('Removing enum parameter=%s from the config due to the invalid value=%s', name, value) logger.warning('Removing enum parameter=%s from the config due to the invalid value=%s', name, value)
@@ -80,469 +130,379 @@ class Enum(namedtuple('Enum', 'version_from,version_till,possible_values')):
class EnumBool(Enum): class EnumBool(Enum):
def transform(self, name, value): def transform(self, name: str, value: Optional[Any]) -> Optional[Any]:
if parse_bool(value) is not None: if parse_bool(value) is not None:
return value return value
return super(EnumBool, self).transform(name, value) return super(EnumBool, self).transform(name, value)
class String(namedtuple('String', 'version_from,version_till')): class String(_Transformable):
@staticmethod def transform(self, name: str, value: Optional[Any]) -> Optional[Any]:
def transform(name, value):
return value return value
# Format: # Format:
# key - parameter name # key - parameter name
# value - tuple or multiple tuples if something was changing in GUC across postgres versions # value - variable length tuple of `_Transformable` objects. Each object in the tuple represents a different
parameters = CaseInsensitiveDict({ # validation of the GUC across postgres versions. If a GUC validation has never changed over time, then it will
'allow_in_place_tablespaces': Bool(150000, None), # have a single object in the tuple. For example, `password_encryption` used to be a boolean GUC up to Postgres
'allow_system_table_mods': Bool(90300, None), # 10, at which point it started being an enum. In that case the value of `password_encryption` would be a tuple
'application_name': String(90300, None), # of 2 `_Transformable` objects (`Bool` and `Enum`, respectively), each one reprensenting a different
'archive_command': String(90300, None), # validation rule.
'archive_library': String(150000, None), parameters = CaseInsensitiveDict()
'archive_mode': ( recovery_parameters = CaseInsensitiveDict()
Bool(90300, 90500),
EnumBool(90500, None, ('always',))
),
'archive_timeout': Integer(90300, None, 0, 1073741823, 's'),
'array_nulls': Bool(90300, None),
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
'autovacuum': Bool(90300, None),
'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None),
'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None),
'autovacuum_freeze_max_age': Integer(90300, None, 100000, 2000000000, None),
'autovacuum_max_workers': (
Integer(90300, 90600, 1, 8388607, None),
Integer(90600, None, 1, 262143, None)
),
'autovacuum_multixact_freeze_max_age': Integer(90300, None, 10000, 2000000000, None),
'autovacuum_naptime': Integer(90300, None, 1, 2147483, 's'),
'autovacuum_vacuum_cost_delay': (
Integer(90300, 120000, -1, 100, 'ms'),
Real(120000, None, -1, 100, 'ms')
),
'autovacuum_vacuum_cost_limit': Integer(90300, None, -1, 10000, None),
'autovacuum_vacuum_insert_scale_factor': Real(130000, None, 0, 100, None),
'autovacuum_vacuum_insert_threshold': Integer(130000, None, -1, 2147483647, None),
'autovacuum_vacuum_scale_factor': Real(90300, None, 0, 100, None),
'autovacuum_vacuum_threshold': Integer(90300, None, 0, 2147483647, None),
'autovacuum_work_mem': Integer(90400, None, -1, 2147483647, 'kB'),
'backend_flush_after': Integer(90600, None, 0, 256, '8kB'),
'backslash_quote': EnumBool(90300, None, ('safe_encoding',)),
'backtrace_functions': String(130000, None),
'bgwriter_delay': Integer(90300, None, 10, 10000, 'ms'),
'bgwriter_flush_after': Integer(90600, None, 0, 256, '8kB'),
'bgwriter_lru_maxpages': (
Integer(90300, 100000, 0, 1000, None),
Integer(100000, None, 0, 1073741823, None)
),
'bgwriter_lru_multiplier': Real(90300, None, 0, 10, None),
'bonjour': Bool(90300, None),
'bonjour_name': String(90300, None),
'bytea_output': Enum(90300, None, ('escape', 'hex')),
'check_function_bodies': Bool(90300, None),
'checkpoint_completion_target': Real(90300, None, 0, 1, None),
'checkpoint_flush_after': Integer(90600, None, 0, 256, '8kB'),
'checkpoint_segments': Integer(90300, 90500, 1, 2147483647, None),
'checkpoint_timeout': (
Integer(90300, 90600, 30, 3600, 's'),
Integer(90600, None, 30, 86400, '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_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
'debug1', 'log', 'notice', 'warning', 'error')),
'cluster_name': String(90500, None),
'commit_delay': Integer(90300, None, 0, 100000, None),
'commit_siblings': Integer(90300, None, 0, 1000, None),
'compute_query_id': (
EnumBool(140000, 150000, ('auto',)),
EnumBool(150000, None, ('auto', 'regress'))
),
'config_file': String(90300, None),
'constraint_exclusion': EnumBool(90300, None, ('partition',)),
'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
'cpu_operator_cost': Real(90300, None, 0, 1.79769e+308, None),
'cpu_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
'cursor_tuple_fraction': Real(90300, None, 0, 1, None),
'data_directory': String(90300, None),
'data_sync_retry': Bool(90400, None),
'DateStyle': String(90300, None),
'db_user_namespace': Bool(90300, None),
'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'),
'debug_discard_caches': Integer(150000, None, 0, 0, None),
'debug_pretty_print': Bool(90300, None),
'debug_print_parse': Bool(90300, None),
'debug_print_plan': Bool(90300, None),
'debug_print_rewritten': Bool(90300, None),
'default_statistics_target': Integer(90300, None, 1, 10000, None),
'default_table_access_method': String(120000, None),
'default_tablespace': 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_isolation': Enum(90300, None, ('serializable', 'repeatable read',
'read committed', 'read uncommitted')),
'default_transaction_read_only': Bool(90300, None),
'default_with_oids': Bool(90300, 120000),
'dynamic_library_path': String(90300, None),
'dynamic_shared_memory_type': (
Enum(90400, 120000, ('posix', 'sysv', 'mmap', 'none')),
Enum(120000, None, ('posix', 'sysv', 'mmap'))
),
'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'),
'effective_io_concurrency': Integer(90300, None, 0, 1000, None),
'enable_async_append': Bool(140000, None),
'enable_bitmapscan': Bool(90300, None),
'enable_gathermerge': Bool(100000, None),
'enable_hashagg': Bool(90300, None),
'enable_hashjoin': Bool(90300, None),
'enable_incremental_sort': Bool(130000, None),
'enable_indexonlyscan': Bool(90300, None),
'enable_indexscan': Bool(90300, None),
'enable_material': Bool(90300, None),
'enable_memoize': Bool(150000, None),
'enable_mergejoin': Bool(90300, None),
'enable_nestloop': Bool(90300, None),
'enable_parallel_append': Bool(110000, None),
'enable_parallel_hash': Bool(110000, None),
'enable_partition_pruning': Bool(110000, None),
'enable_partitionwise_aggregate': Bool(110000, None),
'enable_partitionwise_join': Bool(110000, None),
'enable_seqscan': Bool(90300, None),
'enable_sort': Bool(90300, None),
'enable_tidscan': Bool(90300, None),
'escape_string_warning': Bool(90300, None),
'event_source': String(90300, None),
'exit_on_error': Bool(90300, None),
'extension_destdir': String(140000, None),
'external_pid_file': String(90300, None),
'extra_float_digits': Integer(90300, None, -15, 3, None),
'force_parallel_mode': EnumBool(90600, None, ('regress',)),
'from_collapse_limit': Integer(90300, None, 1, 2147483647, None),
'fsync': Bool(90300, None),
'full_page_writes': Bool(90300, None),
'geqo': Bool(90300, None),
'geqo_effort': Integer(90300, None, 1, 10, None),
'geqo_generations': Integer(90300, None, 0, 2147483647, None),
'geqo_pool_size': Integer(90300, None, 0, 2147483647, None),
'geqo_seed': Real(90300, None, 0, 1, None),
'geqo_selection_bias': Real(90300, None, 1.5, 2, None),
'geqo_threshold': Integer(90300, None, 2, 2147483647, None),
'gin_fuzzy_search_limit': Integer(90300, None, 0, 2147483647, None),
'gin_pending_list_limit': Integer(90500, None, 64, 2147483647, 'kB'),
'hash_mem_multiplier': Real(130000, None, 1, 1000, None),
'hba_file': String(90300, None),
'hot_standby': Bool(90300, None),
'hot_standby_feedback': Bool(90300, None),
'huge_pages': EnumBool(90400, None, ('try',)),
'huge_page_size': Integer(140000, None, 0, 2147483647, 'kB'),
'ident_file': String(90300, None),
'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_invalid_pages': Bool(130000, None),
'ignore_system_indexes': Bool(90300, None),
'IntervalStyle': Enum(90300, None, ('postgres', 'postgres_verbose', 'sql_standard', 'iso_8601')),
'jit': Bool(110000, None),
'jit_above_cost': Real(110000, None, -1, 1.79769e+308, None),
'jit_debugging_support': Bool(110000, None),
'jit_dump_bitcode': Bool(110000, None),
'jit_expressions': Bool(110000, None),
'jit_inline_above_cost': Real(110000, None, -1, 1.79769e+308, None),
'jit_optimize_above_cost': Real(110000, None, -1, 1.79769e+308, None),
'jit_profiling_support': Bool(110000, None),
'jit_provider': String(110000, None),
'jit_tuple_deforming': Bool(110000, None),
'join_collapse_limit': Integer(90300, None, 1, 2147483647, None),
'krb_caseins_users': Bool(90300, None),
'krb_server_keyfile': String(90300, None),
'krb_srvname': String(90300, 90400),
'lc_messages': String(90300, None),
'lc_monetary': String(90300, None),
'lc_numeric': String(90300, None),
'lc_time': String(90300, None),
'listen_addresses': String(90300, None),
'local_preload_libraries': String(90300, None),
'lock_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'lo_compat_privileges': Bool(90300, None),
'log_autovacuum_min_duration': Integer(90300, None, -1, 2147483647, 'ms'),
'log_checkpoints': Bool(90300, None),
'log_connections': Bool(90300, None),
'log_destination': String(90300, None),
'log_directory': String(90300, None),
'log_disconnections': Bool(90300, None),
'log_duration': Bool(90300, None),
'log_error_verbosity': Enum(90300, None, ('terse', 'default', 'verbose')),
'log_executor_stats': Bool(90300, None),
'log_file_mode': Integer(90300, None, 0, 511, None),
'log_filename': String(90300, None),
'logging_collector': Bool(90300, None),
'log_hostname': Bool(90300, None),
'logical_decoding_work_mem': Integer(130000, None, 64, 2147483647, 'kB'),
'log_line_prefix': String(90300, None),
'log_lock_waits': Bool(90300, None),
'log_min_duration_sample': Integer(130000, None, -1, 2147483647, 'ms'),
'log_min_duration_statement': Integer(90300, None, -1, 2147483647, 'ms'),
'log_min_error_statement': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'debug1', 'info',
'notice', 'warning', 'error', 'log', 'fatal', 'panic')),
'log_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'debug1', 'info',
'notice', 'warning', 'error', 'log', 'fatal', 'panic')),
'log_parameter_max_length': 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_planner_stats': Bool(90300, None),
'log_recovery_conflict_waits': Bool(140000, None),
'log_replication_commands': Bool(90500, None),
'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'),
'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'),
'log_startup_progress_interval': Integer(150000, None, 0, 2147483647, 'ms'),
'log_statement': Enum(90300, None, ('none', 'ddl', 'mod', 'all')),
'log_statement_sample_rate': Real(130000, None, 0, 1, None),
'log_statement_stats': Bool(90300, None),
'log_temp_files': Integer(90300, None, -1, 2147483647, 'kB'),
'log_timezone': String(90300, None),
'log_transaction_sample_rate': Real(120000, None, 0, 1, None),
'log_truncate_on_rotation': Bool(90300, None),
'maintenance_io_concurrency': Integer(130000, None, 0, 1000, None),
'maintenance_work_mem': Integer(90300, None, 1024, 2147483647, 'kB'),
'max_connections': (
Integer(90300, 90600, 1, 8388607, None),
Integer(90600, None, 1, 262143, None)
),
'max_files_per_process': (
Integer(90300, 130000, 25, 2147483647, None),
Integer(130000, None, 64, 2147483647, None)
),
'max_locks_per_transaction': Integer(90300, None, 10, 2147483647, None),
'max_logical_replication_workers': Integer(100000, None, 0, 262143, None),
'max_parallel_maintenance_workers': Integer(110000, None, 0, 1024, None),
'max_parallel_workers': Integer(100000, None, 0, 1024, None),
'max_parallel_workers_per_gather': Integer(90600, None, 0, 1024, None),
'max_pred_locks_per_page': Integer(100000, None, 0, 2147483647, None),
'max_pred_locks_per_relation': Integer(100000, None, -2147483648, 2147483647, None),
'max_pred_locks_per_transaction': Integer(90300, None, 10, 2147483647, None),
'max_prepared_transactions': (
Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'max_replication_slots': (
Integer(90400, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'max_slot_wal_keep_size': Integer(130000, None, -1, 2147483647, 'MB'),
'max_stack_depth': Integer(90300, None, 100, 2147483647, 'kB'),
'max_standby_archive_delay': Integer(90300, None, -1, 2147483647, 'ms'),
'max_standby_streaming_delay': Integer(90300, None, -1, 2147483647, 'ms'),
'max_sync_workers_per_subscription': Integer(100000, None, 0, 262143, None),
'max_wal_senders': (
Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'max_wal_size': (
Integer(90500, 100000, 2, 2147483647, '16MB'),
Integer(100000, None, 2, 2147483647, 'MB')
),
'max_worker_processes': (
Integer(90400, 90600, 1, 8388607, 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_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
'min_wal_size': (
Integer(90500, 100000, 2, 2147483647, '16MB'),
Integer(100000, None, 2, 2147483647, 'MB')
),
'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'),
'operator_precedence_warning': Bool(90500, 140000),
'parallel_leader_participation': Bool(110000, None),
'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None),
'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None),
'password_encryption': (
Bool(90300, 100000),
Enum(100000, None, ('md5', 'scram-sha-256'))
),
'plan_cache_mode': Enum(120000, None, ('auto', 'force_generic_plan', 'force_custom_plan')),
'port': Integer(90300, None, 1, 65535, None),
'post_auth_delay': Integer(90300, None, 0, 2147, 's'),
'pre_auth_delay': Integer(90300, None, 0, 60, 's'),
'quote_all_identifiers': Bool(90300, None),
'random_page_cost': Real(90300, None, 0, 1.79769e+308, None),
'recovery_init_sync_method': Enum(140000, None, ('fsync', 'syncfs')),
'recovery_prefetch': EnumBool(150000, None, ('try',)),
'recursive_worktable_factor': Real(150000, None, 0.001, 1e+06, None),
'remove_temp_files_after_crash': Bool(140000, None),
'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None),
'restart_after_crash': Bool(90300, None),
'row_security': Bool(90500, None),
'search_path': String(90300, None),
'seq_page_cost': Real(90300, None, 0, 1.79769e+308, None),
'session_preload_libraries': String(90400, None),
'session_replication_role': Enum(90300, None, ('origin', 'replica', 'local')),
'shared_buffers': Integer(90300, None, 16, 1073741823, '8kB'),
'shared_memory_type': Enum(120000, None, ('sysv', 'mmap')),
'shared_preload_libraries': String(90300, None),
'sql_inheritance': Bool(90300, 100000),
'ssl': Bool(90300, None),
'ssl_ca_file': String(90300, None),
'ssl_cert_file': String(90300, None),
'ssl_ciphers': String(90300, None),
'ssl_crl_dir': String(140000, None),
'ssl_crl_file': String(90300, None),
'ssl_dh_params_file': String(100000, None),
'ssl_ecdh_curve': String(90400, None),
'ssl_key_file': String(90300, None),
'ssl_max_protocol_version': Enum(120000, None, ('', 'tlsv1', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3')),
'ssl_min_protocol_version': Enum(120000, None, ('tlsv1', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3')),
'ssl_passphrase_command': String(110000, None),
'ssl_passphrase_command_supports_reload': Bool(110000, None),
'ssl_prefer_server_ciphers': Bool(90400, None),
'ssl_renegotiation_limit': Integer(90300, 90500, 0, 2147483647, 'kB'),
'standard_conforming_strings': Bool(90300, None),
'statement_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'stats_fetch_consistency': Enum(150000, None, ('none', 'cache', 'snapshot')),
'stats_temp_directory': String(90300, 150000),
'superuser_reserved_connections': (
Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'synchronize_seqscans': Bool(90300, None),
'synchronous_commit': (
EnumBool(90300, 90600, ('local', 'remote_write')),
EnumBool(90600, None, ('local', 'remote_write', 'remote_apply'))
),
'synchronous_standby_names': String(90300, None),
'syslog_facility': Enum(90300, None, ('local0', 'local1', 'local2', 'local3',
'local4', 'local5', 'local6', 'local7')),
'syslog_ident': String(90300, None),
'syslog_sequence_numbers': Bool(90600, None),
'syslog_split_messages': Bool(90600, None),
'tcp_keepalives_count': Integer(90300, None, 0, 2147483647, None),
'tcp_keepalives_idle': Integer(90300, None, 0, 2147483647, 's'),
'tcp_keepalives_interval': Integer(90300, None, 0, 2147483647, 's'),
'tcp_user_timeout': Integer(120000, None, 0, 2147483647, 'ms'),
'temp_buffers': Integer(90300, None, 100, 1073741823, '8kB'),
'temp_file_limit': Integer(90300, None, -1, 2147483647, 'kB'),
'temp_tablespaces': String(90300, None),
'TimeZone': String(90300, None),
'timezone_abbreviations': String(90300, None),
'trace_notify': Bool(90300, None),
'trace_recovery_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
'debug1', 'log', 'notice', 'warning', 'error')),
'trace_sort': Bool(90300, None),
'track_activities': Bool(90300, None),
'track_activity_query_size': (
Integer(90300, 110000, 100, 102400, None),
Integer(110000, 130000, 100, 102400, 'B'),
Integer(130000, None, 100, 1048576, 'B')
),
'track_commit_timestamp': Bool(90500, None),
'track_counts': Bool(90300, None),
'track_functions': Enum(90300, None, ('none', 'pl', 'all')),
'track_io_timing': Bool(90300, None),
'track_wal_io_timing': Bool(140000, None),
'transaction_deferrable': Bool(90300, None),
'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
'read committed', 'read uncommitted')),
'transaction_read_only': Bool(90300, None),
'transform_null_equals': Bool(90300, None),
'unix_socket_directories': String(90300, None),
'unix_socket_group': String(90300, None),
'unix_socket_permissions': Integer(90300, None, 0, 511, None),
'update_process_title': Bool(90300, None),
'vacuum_cleanup_index_scale_factor': Real(110000, 140000, 0, 1e+10, None),
'vacuum_cost_delay': (
Integer(90300, 120000, 0, 100, 'ms'),
Real(120000, None, 0, 100, 'ms')
),
'vacuum_cost_limit': Integer(90300, None, 1, 10000, None),
'vacuum_cost_page_dirty': 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_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_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_table_age': Integer(90300, None, 0, 2000000000, None),
'wal_buffers': Integer(90300, None, -1, 262143, '8kB'),
'wal_compression': (
Bool(90500, 150000),
EnumBool(150000, None, ('pglz', 'lz4', 'zstd'))
),
'wal_consistency_checking': String(100000, None),
'wal_decode_buffer_size': Integer(150000, None, 65536, 1073741823, 'B'),
'wal_init_zero': Bool(120000, None),
'wal_keep_segments': Integer(90300, 130000, 0, 2147483647, None),
'wal_keep_size': Integer(130000, None, 0, 2147483647, 'MB'),
'wal_level': (
Enum(90300, 90400, ('minimal', 'archive', 'hot_standby')),
Enum(90400, 90600, ('minimal', 'archive', 'hot_standby', 'logical')),
Enum(90600, None, ('minimal', 'replica', 'logical'))
),
'wal_log_hints': Bool(90400, None),
'wal_receiver_create_temp_slot': Bool(130000, None),
'wal_receiver_status_interval': Integer(90300, None, 0, 2147483, 's'),
'wal_receiver_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'wal_recycle': Bool(120000, None),
'wal_retrieve_retry_interval': Integer(90500, None, 1, 2147483647, 'ms'),
'wal_sender_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'wal_skip_threshold': Integer(130000, None, 0, 2147483647, 'kB'),
'wal_sync_method': Enum(90300, None, ('fsync', 'fdatasync', 'open_sync', 'open_datasync')),
'wal_writer_delay': Integer(90300, None, 1, 10000, 'ms'),
'wal_writer_flush_after': Integer(90600, None, 0, 2147483647, '8kB'),
'work_mem': Integer(90300, None, 64, 2147483647, 'kB'),
'xmlbinary': Enum(90300, None, ('base64', 'hex')),
'xmloption': Enum(90300, None, ('content', 'document')),
'zero_damaged_pages': Bool(90300, None)
})
recovery_parameters = CaseInsensitiveDict({ class ValidatorFactoryNoType(PatroniException):
'archive_cleanup_command': String(90300, None), """Raised when a validator spec misses a type."""
'pause_at_recovery_target': Bool(90300, 90500),
'primary_conninfo': String(90300, None),
'primary_slot_name': String(90400, None),
'promote_trigger_file': String(120000, None),
'recovery_end_command': String(90300, None),
'recovery_min_apply_delay': Integer(90400, None, 0, 2147483647, 'ms'),
'recovery_target': Enum(90400, None, ('immediate', '')),
'recovery_target_action': Enum(90500, None, ('pause', 'promote', 'shutdown')),
'recovery_target_inclusive': Bool(90300, None),
'recovery_target_lsn': String(100000, None),
'recovery_target_name': String(90400, None),
'recovery_target_time': String(90300, None),
'recovery_target_timeline': String(90300, None),
'recovery_target_xid': String(90300, None),
'restore_command': String(90300, None),
'standby_mode': Bool(90300, 120000),
'trigger_file': String(90300, 120000)
})
def _transform_parameter_value(validators, version, name, value): class ValidatorFactoryInvalidType(PatroniException):
validators = validators.get(name) """Raised when a validator spec contains an invalid type."""
if validators:
for validator in (validators if isinstance(validators[0], tuple) else [validators]):
class ValidatorFactoryInvalidSpec(PatroniException):
"""Raised when a validator spec contains an invalid set of attributes."""
class ValidatorFactory:
"""Factory class used to build Patroni validator objects based on the given specs."""
TYPES: Dict[str, Type[_Transformable]] = {cls.__name__: cls for cls in _Transformable.get_subclasses()}
def __new__(cls, validator: Dict[str, Any]) -> _Transformable:
"""Parse a given Postgres GUC *validator* into the corresponding Patroni validator object.
:param validator: a validator spec for a given parameter. It usually comes from a parsed YAML file.
:returns: the Patroni validator object that corresponds to the specification found in *validator*.
:raises :class:`ValidatorFactoryNoType`: if *validator* contains no ``type`` key.
:raises :class:`ValidatorFactoryInvalidType`: if ``type`` key from *validator* contains an invalid value.
:raises :class:`ValidatorFactoryInvalidSpec`: if *validator* contains an invalid set of attributes for the
given ``type``.
:Example:
If a given validator was defined as follows in the YAML file:
```yaml
- type: String
version_from: 90300
version_till: null
```
Then this method would receive *validator* as:
```python
{
'type': 'String',
'version_from': 90300,
'version_till': None
}
```
And this method would return a :class:`String`:
```python
String(90300, None)
```
"""
validator = deepcopy(validator)
try:
type_ = validator.pop('type')
except KeyError as exc:
raise ValidatorFactoryNoType('Validator contains no type.') from exc
if type_ not in cls.TYPES:
raise ValidatorFactoryInvalidType(f'Unexpected validator type: `{type_}`.')
for key, value in validator.items():
# :func:`_transform_parameter_value` expects :class:`tuple` instead of :class:`list`
if isinstance(value, list):
tmp_value: List[Any] = value
validator[key] = tuple(tmp_value)
try:
return cls.TYPES[type_](**validator)
except Exception as exc:
raise ValidatorFactoryInvalidSpec(
f'Failed to parse `{type_}` validator (`{validator}`): `{str(exc)}`.') from exc
def _get_postgres_guc_validators(config: Dict[str, Any], parameter: str) -> Tuple[_Transformable, ...]:
"""Get all validators of *parameter* from *config*.
Loop over all validators specs of *parameter* and return them parsed as Patroni validators.
:param config: Python object corresponding to an YAML file, with values of either ``parameters`` or
``recovery_parameters`` key.
:param parameter: name of the parameter found under *config* which validators should be parsed and returned.
:rtype: yields any exception that is faced while parsing a validator spec into a Patroni validator object.
"""
validators: List[_Transformable] = []
for validator_spec in config.get(parameter, []):
try:
validator = ValidatorFactory(validator_spec)
validators.append(validator)
except (ValidatorFactoryNoType, ValidatorFactoryInvalidType, ValidatorFactoryInvalidSpec) as exc:
logger.warning('Faced an issue while parsing a validator for parameter `%s`: `%r`', parameter, exc)
return tuple(validators)
class InvalidGucValidatorsFile(PatroniException):
"""Raised when reading or parsing of a YAML file faces an issue."""
def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
"""Read an YAML file and return the corresponding Python object.
:param file: path to the file to be read. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
return ``None``.
:raises :class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
"""
try:
with open(file, encoding='UTF-8') as stream:
return yaml.safe_load(stream)
except Exception as exc:
raise InvalidGucValidatorsFile(
f'Unexpected issue while reading parameters file `{file}`: `{str(exc)}`.') from exc
def _load_postgres_gucs_validators() -> None:
"""Load all Postgres GUC validators from YAML files.
Recursively walk through ``available_parameters`` directory and load validators of each found YAML file into
``parameters`` and/or ``recovery_parameters`` variables.
Walk through directories in top-down fashion and for each of them:
* Sort files by name;
* Load validators from YAML files that were found.
Any problem faced while reading or parsing files will be logged as a ``WARNING`` by the child function, and the
corresponding file or validator will be ignored.
By default Patroni only ships the file ``0_postgres.yml``, which contains Community Postgres GUCs validators, but
that behavior can be extended. For example: if a vendor wants to add GUC validators to Patroni for covering a custom
Postgres build, then they can create their custom YAML files under ``available_parameters`` directory.
Each YAML file may contain either or both of these root attributes, here called sections:
* ``parameters``: general GUCs that would be written to ``postgresql.conf``;
* ``recovery_parameters``: recovery related GUCs that would be written to ``recovery.conf`` (Patroni later
writes them to ``postgresql.conf`` if running PG 12 and above).
Then, each of these sections, if specified, may contain one or more attributes with the following structure:
* key: the name of a GUC;
* value: a list of validators. Each item in the list must contain a ``type`` attribute, which must be one among:
* ``Bool``; or
* ``Integer``; or
* ``Real``; or
* ``Enum``; or
* ``EnumBool``; or
* ``String``.
Besides the ``type`` attribute, it should also contain all the required attributes as per the corresponding
class in this module.
.. seealso::
* :class:`Bool`;
* :class:`Integer`;
* :class:`Real`;
* :class:`Enum`;
* :class:`EnumBool`;
* :class:`String`.
:Example:
This is a sample content for an YAML file based on Postgres GUCs, showing each of the supported types and
sections:
```yaml
parameters:
archive_command:
- type: String
version_from: 90300
version_till: null
archive_mode:
- type: Bool
version_from: 90300
version_till: 90500
- type: EnumBool
version_from: 90500
version_till: null
possible_values:
- always
archive_timeout:
- type: Integer
version_from: 90300
version_till: null
min_val: 0
max_val: 1073741823
unit: s
autovacuum_vacuum_cost_delay:
- type: Integer
version_from: 90300
version_till: 120000
min_val: -1
max_val: 100
unit: ms
- type: Real
version_from: 120000
version_till: null
min_val: -1
max_val: 100
unit: ms
client_min_messages:
- type: Enum
version_from: 90300
version_till: null
possible_values:
- debug5
- debug4
- debug3
- debug2
- debug1
- log
- notice
- warning
- error
recovery_parameters:
archive_cleanup_command:
- type: String
version_from: 90300
version_till: null
```
"""
conf_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'available_parameters',
)
yaml_files: List[str] = []
for root, _, files in os.walk(conf_dir):
for file in sorted(files):
full_path = os.path.join(root, file)
if file.lower().endswith(('.yml', '.yaml')):
yaml_files.append(full_path)
else:
logger.info('Ignored a non-YAML file found under `available_parameters` directory: `%s`.', full_path)
for file in yaml_files:
try:
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
except InvalidGucValidatorsFile as exc:
logger.warning(str(exc))
continue
logger.debug(f'Parsing validators from file `{file}`.')
mapping = {
'parameters': parameters,
'recovery_parameters': recovery_parameters,
}
for section in ['parameters', 'recovery_parameters']:
section_var = mapping[section]
config_section = config.get(section, {})
for parameter in config_section.keys():
section_var[parameter] = _get_postgres_guc_validators(config_section, parameter)
_load_postgres_gucs_validators()
def _transform_parameter_value(validators: MutableMapping[str, Tuple[_Transformable, ...]],
version: int, name: str, value: Any,
available_gucs: CaseInsensitiveSet) -> Optional[Any]:
"""Validate *value* of GUC *name* for Postgres *version* using defined *validators* and *available_gucs*.
:param validators: a dictionary of all GUCs across all Postgres versions. Each key is the name of a Postgres GUC,
and the corresponding value is a variable length tuple of :class:`_Transformable`. Each item is a validation
rule for the GUC for a given range of Postgres versions. Should either contain recovery GUCs or general GUCs,
not both.
:param version: Postgres version to validate the GUC against.
:param name: name of the Postgres GUC.
:param value: value of the Postgres GUC.
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
GUC. Used for a couple purposes:
* Disallow writing GUCs to ``postgresql.conf`` (or ``recovery.conf``) that does not exist in Postgres *version*;
* Avoid ignoring GUC *name* if it does not have a validator in *validators*, but is a valid GUC in Postgres
*version*.
:returns: the return value may be one among:
* *value* transformed to the expected format for GUC *name* in Postgres *version*, if *name* is present in
*available_gucs* and has a validator in *validators* for the corresponding Postgres *version*; or
* The own *value* if *name* is present in *available_gucs* but not in *validators*; or
* ``None`` if *name* is not present in *available_gucs*.
"""
if name in available_gucs:
for validator in validators.get(name, ()) or ():
if version >= validator.version_from and\ if version >= validator.version_from and\
(validator.version_till is None or version < validator.version_till): (validator.version_till is None or version < validator.version_till):
return validator.transform(name, value) return validator.transform(name, value)
# Ideally we should have a validator in *validators*. However, if none is available, we will not discard a
# setting that exists in Postgres *version*, but rather allow the value with no validation.
return value
logger.warning('Removing unexpected parameter=%s value=%s from the config', name, value) logger.warning('Removing unexpected parameter=%s value=%s from the config', name, value)
def transform_postgresql_parameter_value(version, name, value): def transform_postgresql_parameter_value(version: int, name: str, value: Any,
if '.' in name: available_gucs: CaseInsensitiveSet) -> Optional[Any]:
"""Validate *value* of GUC *name* for Postgres *version* using ``parameters`` and *available_gucs*.
:param version: Postgres version to validate the GUC against.
:param name: name of the Postgres GUC.
:param value: value of the Postgres GUC.
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
GUC. Used for a couple purposes:
* Disallow writing GUCs to ``postgresql.conf`` that does not exist in Postgres *version*;
* Avoid ignoring GUC *name* if it does not have a validator in ``parameters``, but is a valid GUC in Postgres
*version*.
:returns: The return value may be one among
* The original *value* if *name* seems to be an extension GUC (contains a period '.'); or
* ``None`` if **name** is a recovery GUC; or
* *value* transformed to the expected format for GUC *name* in Postgres *version* using validators defined in
``parameters``. Can also return ``None``. See :func:`_transform_parameter_value`.
"""
if '.' in name and name not in parameters:
# likely an extension GUC, so just return as it is. Otherwise, if `name` is in `parameters`, it's likely a
# namespaced GUC from a custom Postgres build, so we treat that over the usual validation means.
return value return value
if name in recovery_parameters: if name in recovery_parameters:
return None return None
return _transform_parameter_value(parameters, version, name, value) return _transform_parameter_value(parameters, version, name, value, available_gucs)
def transform_recovery_parameter_value(version, name, value): def transform_recovery_parameter_value(version: int, name: str, value: Any,
return _transform_parameter_value(recovery_parameters, version, name, value) available_gucs: CaseInsensitiveSet) -> Optional[Any]:
"""Validate *value* of GUC *name* for Postgres *version* using ``recovery_parameters`` and *available_gucs*.
:param version: Postgres version to validate the recovery GUC against.
:param name: name of the Postgres recovery GUC.
:param value: value of the Postgres recovery GUC.
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
GUC. Used for a couple purposes:
* Disallow writing GUCs to ``recovery.conf`` (or ``postgresql.conf`` depending on *version*), that does not
exist in Postgres *version*;
* Avoid ignoring recovery GUC *name* if it does not have a validator in ``recovery_parameters``, but is a valid
GUC in Postgres *version*.
:returns: *value* transformed to the expected format for recovery GUC *name* in Postgres *version* using validators
defined in ``recovery_parameters``. It can also return ``None``. See :func:`_transform_parameter_value`.
"""
# Recovery settings are not present in ``postgres --describe-config`` output of Postgres <= 11. In that case we
# just pass down the list of settings defined in Patroni validators so :func:`_transform_parameter_value` will not
# discard the recovery GUCs when running Postgres <= 11.
# NOTE: At the moment this change was done Postgres 11 was almost EOL, and had been likely extensively used with
# Patroni, so we should be able to rely solely on Patroni validators as the source of truth.
return _transform_parameter_value(
recovery_parameters, version, name, value,
available_gucs if version >= 120000 else CaseInsensitiveSet(recovery_parameters.keys()))
+77 -9
View File
@@ -1,3 +1,13 @@
"""Abstraction layer for ``psycopg`` module.
This module is able to handle both ``pyscopg2`` and ``psycopg3``, and it exposes a common interface for both.
``psycopg2`` takes precedence. ``psycopg3`` will only be used if ``psycopg2`` is either absent or older than ``2.5.4``.
"""
from typing import Any, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING: # pragma: no cover
from psycopg import Connection
from psycopg2 import connection, cursor
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError'] __all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError']
_legacy = False _legacy = False
@@ -14,7 +24,18 @@ try:
except ImportError: except ImportError:
_legacy = True _legacy = True
def quote_literal(value, conn=None): def quote_literal(value: Any, conn: Optional[Any] = None) -> str:
"""Quote *value* as a SQL literal.
.. note::
*value* is quoted through ``psycopg`` adapters.
:param value: value to be quoted.
:param conn: if a connection is given then :func:`quote_literal` checks if any special handling based on server
parameters needs to be applied to *value* before quoting it as a SQL literal.
:returns: *value* quoted as a SQL literal.
"""
value = adapt(value) value = adapt(value)
if conn: if conn:
value.prepare(conn) value.prepare(conn)
@@ -22,19 +43,58 @@ try:
except ImportError: except ImportError:
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
def _connect(*args, **kwargs): def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
ret = __connect(*args, **kwargs) """Call ``psycopg.connect`` with ``dsn`` and ``**kwargs``.
ret.server_version = ret.pgconn.server_version # compatibility with psycopg2
.. note::
Will create ``server_version`` attribute in the returning connection, so it keeps compatibility with the
object that would be returned by ``psycopg2.connect``.
:param dsn: DSN to call ``psycopg.connect`` with.
:param kwargs: keyword arguments to call ``psycopg.connect`` with.
:returns: a connection to the database.
"""
ret = __connect(dsn or "", **kwargs)
setattr(ret, 'server_version', ret.pgconn.server_version) # compatibility with psycopg2
return ret return ret
def _quote_ident(value, conn): def _quote_ident(value: Any, scope: Any) -> str:
return sql.Identifier(value).as_string(conn) """Quote *value* as a SQL identifier.
def quote_literal(value, conn=None): :param value: value to be quoted.
:param scope: connection to evaluate the returning string into.
:returns: *value* quoted as a SQL identifier.
"""
return sql.Identifier(value).as_string(scope)
def quote_literal(value: Any, conn: Optional[Any] = None) -> str:
"""Quote *value* as a SQL literal.
:param value: value to be quoted.
:param conn: connection to evaluate the returning string into.
:returns: *value* quoted as a SQL literal.
"""
return sql.Literal(value).as_string(conn) return sql.Literal(value).as_string(conn)
def connect(*args, **kwargs): def connect(*args: Any, **kwargs: Any) -> Union['connection', 'Connection[Any]']:
"""Get a connection to the database.
.. note::
The connection will have ``autocommit`` enabled.
It also enforces ``search_path=pg_catalog`` for non-replication connections to mitigate security issues as
Patroni relies on superuser connections.
:param args: positional arguments to call ``connect`` function from ``psycopg`` module.
:param kwargs: keyword arguments to call ``connect`` function from ``psycopg`` module.
:returns: a connection to the database. Can be either a :class:`psycopg.Connection` if using ``psycopg3``, or a
:class:`psycopg2.extensions.connection` if using ``psycopg2``.
"""
if kwargs and 'replication' not in kwargs and kwargs.get('fallback_application_name') != 'Patroni ctl': if kwargs and 'replication' not in kwargs and kwargs.get('fallback_application_name') != 'Patroni ctl':
options = [kwargs['options']] if 'options' in kwargs else [] options = [kwargs['options']] if 'options' in kwargs else []
options.append('-c search_path=pg_catalog') options.append('-c search_path=pg_catalog')
@@ -44,7 +104,15 @@ def connect(*args, **kwargs):
return ret return ret
def quote_ident(value, conn=None): def quote_ident(value: Any, conn: Optional[Union['cursor', 'connection', 'Connection[Any]']] = None) -> str:
"""Quote *value* as a SQL identifier.
:param value: value to be quoted.
:param conn: connection to evaluate the returning string into. Can be either a :class:`psycopg.Connection` if
using ``psycopg3``, or a :class:`psycopg2.extensions.connection` if using ``psycopg2``.
:returns: *value* quoted as a SQL identifier.
"""
if _legacy or conn is None: if _legacy or conn is None:
return '"{0}"'.format(value.replace('"', '""')) return '"{0}"'.format(value.replace('"', '""'))
return _quote_ident(value, conn) return _quote_ident(value, conn)
+13 -9
View File
@@ -1,6 +1,7 @@
import logging import logging
from .daemon import AbstractPatroniDaemon, abstract_main from .config import Config
from .daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser
from .dcs.raft import KVStoreTTL from .dcs.raft import KVStoreTTL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -8,22 +9,25 @@ logger = logging.getLogger(__name__)
class RaftController(AbstractPatroniDaemon): class RaftController(AbstractPatroniDaemon):
def __init__(self, config): def __init__(self, config: Config) -> None:
super(RaftController, self).__init__(config) super(RaftController, self).__init__(config)
config = self.config.get('raft') kvstore_config = self.config.get('raft')
assert 'self_addr' in config assert 'self_addr' in kvstore_config
self._raft = KVStoreTTL(None, None, None, **config) self._raft = KVStoreTTL(None, None, None, **kvstore_config)
def _run_cycle(self): def _run_cycle(self) -> None:
try: try:
self._raft.doTick(self._raft.conf.autoTickPeriod) self._raft.doTick(self._raft.conf.autoTickPeriod)
except Exception: except Exception:
logger.exception('doTick') logger.exception('doTick')
def _shutdown(self): def _shutdown(self) -> None:
self._raft.destroy() self._raft.destroy()
def main(): def main() -> None:
abstract_main(RaftController) parser = get_base_arg_parser()
args = parser.parse_args()
abstract_main(RaftController, args.configfile)
+140 -23
View File
@@ -1,67 +1,184 @@
"""Facilities for handling communication with Patroni's REST API."""
import json import json
import urllib3 import urllib3
from typing import Any, Dict, Optional, Union
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from .config import Config
from .dcs import Member
from .utils import USER_AGENT from .utils import USER_AGENT
class PatroniRequest(object): class HTTPSConnectionPool(urllib3.HTTPSConnectionPool):
def __init__(self, config, insecure=None): def _validate_conn(self, *args: Any, **kwargs: Any) -> None:
"""Override parent method to silence warnings about requests without certificate verification enabled."""
class PatroniPoolManager(urllib3.PoolManager):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super(PatroniPoolManager, self).__init__(*args, **kwargs)
self.pool_classes_by_scheme = {'http': urllib3.HTTPConnectionPool, 'https': HTTPSConnectionPool}
class PatroniRequest(object):
"""Wrapper for performing requests to Patroni's REST API.
Prepares the request manager with the configured settings before performing the request.
"""
def __init__(self, config: Union[Config, Dict[str, Any]], insecure: Optional[bool] = None) -> None:
"""Create a new :class:`PatroniRequest` instance with given *config*.
:param config: Patroni YAML configuration.
:param insecure: how to deal with SSL certs verification
* If ``True`` it will perform REST API requests without verifying SSL certs; or
* If ``False`` it will perform REST API requests and verify SSL certs; or
* If ``None`` it will behave according to the value of ``ctl -> insecure`` configuration; or
* If none of the above applies, then it falls back to ``False``.
"""
self._insecure = insecure self._insecure = insecure
self._pool = urllib3.PoolManager(num_pools=10, maxsize=10) self._pool = PatroniPoolManager(num_pools=10, maxsize=10)
self.reload_config(config) self.reload_config(config)
@staticmethod @staticmethod
def _get_cfg_value(config, name): def _get_ctl_value(config: Union[Config, Dict[str, Any]], name: str, default: Any = None) -> Optional[Any]:
return config.get('ctl', {}).get(name) or config.get('restapi', {}).get(name) """Get value of *name* setting from the ``ctl`` section of the *config*.
def _apply_pool_param(self, param, value): :param config: Patroni YAML configuration.
:param name: name of the setting value to be retrieved.
:returns: value of ``ctl -> *name*`` if present, ``None`` otherwise.
"""
return config.get('ctl', {}).get(name, default)
@staticmethod
def _get_restapi_value(config: Union[Config, Dict[str, Any]], name: str) -> Optional[Any]:
"""Get value of *name* setting from the ``restapi`` section of the *config*.
:param config: Patroni YAML configuration.
:param name: name of the setting value to be retrieved.
:returns: value of ``restapi -> *name*`` if present, ``None`` otherwise.
"""
return config.get('restapi', {}).get(name)
def _apply_pool_param(self, param: str, value: Any) -> None:
"""Configure *param* as *value* in the request manager.
:param param: name of the setting to be changed.
:param value: new value for *param*. If ``None``, ``0``, ``False``, and similar values, then explicit *param*
declaration is removed, in which case it takes its default value, if any.
"""
if value: if value:
self._pool.connection_pool_kw[param] = value self._pool.connection_pool_kw[param] = value
else: else:
self._pool.connection_pool_kw.pop(param, None) self._pool.connection_pool_kw.pop(param, None)
def _apply_ssl_file_param(self, config, name): def _apply_ssl_file_param(self, config: Union[Config, Dict[str, Any]], name: str) -> Union[str, None]:
value = self._get_cfg_value(config, name + 'file') """Apply a given SSL related param to the request manager.
:param config: Patroni YAML configuration.
:param name: prefix of the Patroni SSL related setting name. Currently, supports these:
* ``cert``: gets translated to ``certfile``
* ``key``: gets translated to ``keyfile``
Will attempt to fetch the requested key first from ``ctl`` section.
:returns: value of ``ctl -> *name*file`` if present, ``None`` otherwise.
"""
value = self._get_ctl_value(config, name + 'file')
self._apply_pool_param(name + '_file', value) self._apply_pool_param(name + '_file', value)
return value return value
def reload_config(self, config): def reload_config(self, config: Union[Config, Dict[str, Any]]) -> None:
self._pool.headers = urllib3.make_headers(basic_auth=self._get_cfg_value(config, 'auth'), user_agent=USER_AGENT) """Apply *config* to request manager.
Configure these HTTP headers for requests:
* ``authorization``: based on Patroni' CTL or REST API authentication config;
* ``user-agent``: based on `patroni.utils.USER_AGENT`.
Also configure SSL related settings for requests:
* ``ca_certs`` is configured if ``ctl -> cacert`` or ``restapi -> cafile`` is available;
* ``cert``, ``key`` and ``key_password`` are configured if ``ctl -> certfile`` is available.
:param config: Patroni YAML configuration.
"""
# ``ctl -> auth`` is equivalent to ``ctl -> authentication -> username`` + ``:`` +
# ``ctl -> authentication -> password``. And the same for ``restapi -> auth``
basic_auth = self._get_ctl_value(config, 'auth') or self._get_restapi_value(config, 'auth')
self._pool.headers = urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_REQUIRED'
insecure = self._insecure if isinstance(self._insecure, bool)\
else self._get_ctl_value(config, 'insecure', False)
insecure = self._insecure if isinstance(self._insecure, bool) else config.get('ctl', {}).get('insecure', False)
if self._apply_ssl_file_param(config, 'cert'): if self._apply_ssl_file_param(config, 'cert'):
# With client certificate the cert_reqs must be set to CERT_REQUIRED even if insecure option is used if insecure: # The assert_hostname = False helps to silence warnings
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_REQUIRED' self._pool.connection_pool_kw['assert_hostname'] = False
# The assert_hostname = False helps to silence warnings
self._pool.connection_pool_kw['assert_hostname'] = False if insecure else None
self._apply_ssl_file_param(config, 'key') self._apply_ssl_file_param(config, 'key')
password = self._get_ctl_value(config, 'keyfile_password')
password = self._get_cfg_value(config, 'keyfile_password')
self._apply_pool_param('key_password', password) self._apply_pool_param('key_password', password)
else: else:
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_NONE' if insecure else 'CERT_REQUIRED' if insecure: # Disable server certificate validation if requested
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_NONE'
self._pool.connection_pool_kw.pop('assert_hostname', None)
self._pool.connection_pool_kw.pop('key_file', None) self._pool.connection_pool_kw.pop('key_file', None)
cacert = config.get('ctl', {}).get('cacert') or config.get('restapi', {}).get('cafile') cacert = self._get_ctl_value(config, 'cacert') or self._get_restapi_value(config, 'cafile')
self._apply_pool_param('ca_certs', cacert) self._apply_pool_param('ca_certs', cacert)
def request(self, method, url, body=None, **kwargs): def request(self, method: str, url: str, body: Optional[Any] = None,
**kwargs: Any) -> urllib3.response.HTTPResponse:
"""Perform an HTTP request.
:param method: the HTTP method to be used, e.g. ``GET``.
:param url: the URL to be requested.
:param body: anything to be used as the request body.
:param kwargs: keyword arguments to be passed to :func:`urllib3.PoolManager.request`.
:returns: the response returned upon request.
"""
if body is not None and not isinstance(body, str): if body is not None and not isinstance(body, str):
body = json.dumps(body) body = json.dumps(body)
return self._pool.request(method.upper(), url, body=body, **kwargs) return self._pool.request(method.upper(), url, body=body, **kwargs)
def __call__(self, member, method='GET', endpoint=None, data=None, **kwargs): def __call__(self, member: Member, method: str = 'GET', endpoint: Optional[str] = None,
url = member.api_url data: Optional[Any] = None, **kwargs: Any) -> urllib3.response.HTTPResponse:
"""Turn :class:`PatroniRequest` into a callable object.
When called, perform a request through the manager.
:param member: DCS member so we can fetch from it the configured base URL for the REST API.
:param method: HTTP method to be used, e.g. ``GET``.
:param endpoint: URL path of this request, e.g. ``switchover``.
:param data: anything to be used as the request body.
:returns: the response returned upon request.
"""
url = member.api_url or ''
if endpoint: if endpoint:
scheme, netloc, _, _, _, _ = urlparse(url) scheme, netloc, _, _, _, _ = urlparse(url)
url = urlunparse((scheme, netloc, endpoint, '', '', '')) url = urlunparse((scheme, netloc, endpoint, '', '', ''))
return self.request(method, url, data, **kwargs) return self.request(method, url, data, **kwargs)
def get(url, verify=True, **kwargs): def get(url: str, verify: bool = True, **kwargs: Any) -> urllib3.response.HTTPResponse:
"""Perform an HTTP GET request.
.. note::
It uses :class:`PatroniRequest` so all relevant configuration is applied before processing the request.
:param url: full URL for this GET request.
:param verify: if it should verify SSL certificates when processing the request.
:returns: the response returned from the request.
"""
http = PatroniRequest({}, not verify) http = PatroniRequest({}, not verify)
return http.request('GET', url, **kwargs) return http.request('GET', url, **kwargs)
+12 -10
View File
@@ -5,20 +5,22 @@ import logging
import sys import sys
import boto3 import boto3
from ..utils import Retry, RetryFailedError
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from botocore.utils import IMDSFetcher from botocore.utils import IMDSFetcher
from typing import Any, Optional
from ..utils import Retry, RetryFailedError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AWSConnection(object): class AWSConnection(object):
def __init__(self, cluster_name): def __init__(self, cluster_name: Optional[str]) -> None:
self.available = False self.available = False
self.cluster_name = cluster_name if cluster_name is not None else 'unknown' self.cluster_name = cluster_name if cluster_name is not None else 'unknown'
self._retry = Retry(deadline=300, max_delay=30, max_tries=-1, retry_exceptions=(ClientError,)) self._retry = Retry(deadline=300, max_delay=30, max_tries=-1, retry_exceptions=ClientError)
try: try:
# get the instance id # get the instance id
fetcher = IMDSFetcher(timeout=2.1) fetcher = IMDSFetcher(timeout=2.1)
@@ -38,13 +40,13 @@ class AWSConnection(object):
return return
self.available = True self.available = True
def retry(self, *args, **kwargs): def retry(self, *args: Any, **kwargs: Any) -> Any:
return self._retry.copy()(*args, **kwargs) return self._retry.copy()(*args, **kwargs)
def aws_available(self): def aws_available(self) -> bool:
return self.available return self.available
def _tag_ebs(self, conn, role): def _tag_ebs(self, conn: Any, role: str) -> None:
""" set tags, carrying the cluster name, instance role and instance id for the EBS storage """ """ set tags, carrying the cluster name, instance role and instance id for the EBS storage """
tags = [{'Key': 'Name', 'Value': 'spilo_' + self.cluster_name}, tags = [{'Key': 'Name', 'Value': 'spilo_' + self.cluster_name},
{'Key': 'Role', 'Value': role}, {'Key': 'Role', 'Value': role},
@@ -52,16 +54,16 @@ class AWSConnection(object):
volumes = conn.volumes.filter(Filters=[{'Name': 'attachment.instance-id', 'Values': [self.instance_id]}]) volumes = conn.volumes.filter(Filters=[{'Name': 'attachment.instance-id', 'Values': [self.instance_id]}])
conn.create_tags(Resources=[v.id for v in volumes], Tags=tags) conn.create_tags(Resources=[v.id for v in volumes], Tags=tags)
def _tag_ec2(self, conn, role): def _tag_ec2(self, conn: Any, role: str) -> None:
""" tag the current EC2 instance with a cluster role """ """ tag the current EC2 instance with a cluster role """
tags = [{'Key': 'Role', 'Value': role}] tags = [{'Key': 'Role', 'Value': role}]
conn.create_tags(Resources=[self.instance_id], Tags=tags) conn.create_tags(Resources=[self.instance_id], Tags=tags)
def on_role_change(self, new_role): def on_role_change(self, new_role: str) -> bool:
if not self.available: if not self.available:
return False return False
try: try:
conn = boto3.resource('ec2', region_name=self.region) conn = boto3.resource('ec2', region_name=self.region) # type: ignore
self.retry(self._tag_ec2, conn, new_role) self.retry(self._tag_ec2, conn, new_role)
self.retry(self._tag_ebs, conn, new_role) self.retry(self._tag_ebs, conn, new_role)
except RetryFailedError: except RetryFailedError:
+33 -34
View File
@@ -31,7 +31,8 @@ import subprocess
import sys import sys
import time import time
from collections import namedtuple from enum import IntEnum
from typing import Any, List, NamedTuple, Optional, Tuple, TYPE_CHECKING
from .. import psycopg from .. import psycopg
@@ -42,15 +43,14 @@ si_prefixes = ['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
# Meaningful names to the exit codes used by WALERestore # Meaningful names to the exit codes used by WALERestore
ExitCode = type('Enum', (), { class ExitCode(IntEnum):
'SUCCESS': 0, #: Succeeded SUCCESS = 0 #: Succeeded
'RETRY_LATER': 1, #: External issue, retry later RETRY_LATER = 1 #: External issue, retry later
'FAIL': 2 #: Don't try again unless configuration changes FAIL = 2 #: Don't try again unless configuration changes
})
# We need to know the current PG version in order to figure out the correct WAL directory name # We need to know the current PG version in order to figure out the correct WAL directory name
def get_major_version(data_dir): def get_major_version(data_dir: str) -> float:
version_file = os.path.join(data_dir, 'PG_VERSION') version_file = os.path.join(data_dir, 'PG_VERSION')
if os.path.isfile(version_file): # version file exists if os.path.isfile(version_file): # version file exists
try: try:
@@ -61,7 +61,7 @@ def get_major_version(data_dir):
return 0.0 return 0.0
def repr_size(n_bytes): def repr_size(n_bytes: float) -> str:
""" """
>>> repr_size(1000) >>> repr_size(1000)
'1000 Bytes' '1000 Bytes'
@@ -77,7 +77,7 @@ def repr_size(n_bytes):
return '{0} {1}iB'.format(round(n_bytes, 1), si_prefixes[i]) return '{0} {1}iB'.format(round(n_bytes, 1), si_prefixes[i])
def size_as_bytes(size_, prefix): def size_as_bytes(size: float, prefix: str) -> int:
""" """
>>> size_as_bytes(7.5, 'T') >>> size_as_bytes(7.5, 'T')
8246337208320 8246337208320
@@ -88,23 +88,19 @@ def size_as_bytes(size_, prefix):
exponent = si_prefixes.index(prefix) + 1 exponent = si_prefixes.index(prefix) + 1
return int(size_ * (1024.0 ** exponent)) return int(size * (1024.0 ** exponent))
WALEConfig = namedtuple( class WALEConfig(NamedTuple):
'WALEConfig', env_dir: str
[ threshold_mb: int
'env_dir', threshold_pct: int
'threshold_mb', cmd: List[str]
'threshold_pct',
'cmd',
]
)
class WALERestore(object): class WALERestore(object):
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, def __init__(self, scope: str, datadir: str, connstring: str, env_dir: str, threshold_mb: int,
threshold_pct, use_iam, no_leader, retries): threshold_pct: int, use_iam: int, no_leader: bool, retries: int) -> None:
self.scope = scope self.scope = scope
self.leader_connection = connstring self.leader_connection = connstring
self.data_dir = datadir self.data_dir = datadir
@@ -129,7 +125,7 @@ class WALERestore(object):
self.init_error = (not os.path.exists(self.wal_e.env_dir)) self.init_error = (not os.path.exists(self.wal_e.env_dir))
self.retries = retries self.retries = retries
def run(self): def run(self) -> int:
""" """
Creates a new replica using WAL-E Creates a new replica using WAL-E
@@ -158,7 +154,7 @@ class WALERestore(object):
logger.exception("Unhandled exception when running WAL-E restore") logger.exception("Unhandled exception when running WAL-E restore")
return ExitCode.FAIL return ExitCode.FAIL
def should_use_s3_to_create_replica(self): def should_use_s3_to_create_replica(self) -> Optional[bool]:
""" determine whether it makes sense to use S3 and not pg_basebackup """ """ determine whether it makes sense to use S3 and not pg_basebackup """
threshold_megabytes = self.wal_e.threshold_mb threshold_megabytes = self.wal_e.threshold_mb
@@ -218,7 +214,7 @@ class WALERestore(object):
try: try:
# get the difference in bytes between the current WAL location and the backup start offset # get the difference in bytes between the current WAL location and the backup start offset
con = psycopg.connect(self.leader_connection) con = psycopg.connect(self.leader_connection)
if con.server_version >= 100000: if getattr(con, 'server_version', 0) >= 100000:
wal_name = 'wal' wal_name = 'wal'
lsn_name = 'lsn' lsn_name = 'lsn'
else: else:
@@ -232,8 +228,9 @@ class WALERestore(object):
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), %s)::bigint" " ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), %s)::bigint"
" END").format(wal_name, lsn_name), " END").format(wal_name, lsn_name),
(backup_start_lsn, backup_start_lsn, backup_start_lsn)) (backup_start_lsn, backup_start_lsn, backup_start_lsn))
for row in cur:
diff_in_bytes = int(cur.fetchone()[0]) diff_in_bytes = int(row[0])
break
except psycopg.Error: except psycopg.Error:
logger.exception('could not determine difference with the leader location') logger.exception('could not determine difference with the leader location')
if attempts_no < self.retries: # retry in case of a temporarily connection issue if attempts_no < self.retries: # retry in case of a temporarily connection issue
@@ -262,11 +259,11 @@ class WALERestore(object):
are_thresholds_ok = is_size_thresh_ok and is_percentage_thresh_ok are_thresholds_ok = is_size_thresh_ok and is_percentage_thresh_ok
class Size(object): class Size(object):
def __init__(self, n_bytes, prefix=None): def __init__(self, n_bytes: float, prefix: Optional[str] = None) -> None:
self.n_bytes = n_bytes self.n_bytes = n_bytes
self.prefix = prefix self.prefix = prefix
def __repr__(self): def __repr__(self) -> str:
if self.prefix is not None: if self.prefix is not None:
n_bytes = size_as_bytes(self.n_bytes, self.prefix) n_bytes = size_as_bytes(self.n_bytes, self.prefix)
else: else:
@@ -274,10 +271,10 @@ class WALERestore(object):
return repr_size(n_bytes) return repr_size(n_bytes)
class HumanContext(object): class HumanContext(object):
def __init__(self, items): def __init__(self, items: List[Tuple[str, Any]]) -> None:
self.items = items self.items = items
def __repr__(self): def __repr__(self) -> str:
return ', '.join('{}={!r}'.format(key, value) return ', '.join('{}={!r}'.format(key, value)
for key, value in self.items) for key, value in self.items)
@@ -298,7 +295,7 @@ class WALERestore(object):
logger.info('Thresholds are OK, using wal-e basebackup: %s', human_context) logger.info('Thresholds are OK, using wal-e basebackup: %s', human_context)
return are_thresholds_ok return are_thresholds_ok
def fix_subdirectory_path_if_broken(self, dirname): def fix_subdirectory_path_if_broken(self, dirname: str) -> bool:
# in case it is a symlink pointing to a non-existing location, remove it and create the actual directory # in case it is a symlink pointing to a non-existing location, remove it and create the actual directory
path = os.path.join(self.data_dir, dirname) path = os.path.join(self.data_dir, dirname)
if not os.path.exists(path): if not os.path.exists(path):
@@ -316,7 +313,7 @@ class WALERestore(object):
return False return False
return True return True
def create_replica_with_s3(self): def create_replica_with_s3(self) -> int:
# if we're set up, restore the replica using fetch latest # if we're set up, restore the replica using fetch latest
try: try:
cmd = self.wal_e.cmd + ['backup-fetch', cmd = self.wal_e.cmd + ['backup-fetch',
@@ -334,7 +331,7 @@ class WALERestore(object):
return exit_code return exit_code
def main(): def main() -> int:
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO) logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
parser = argparse.ArgumentParser(description='Script to image replicas using WAL-E') parser = argparse.ArgumentParser(description='Script to image replicas using WAL-E')
parser.add_argument('--scope', required=True) parser.add_argument('--scope', required=True)
@@ -363,11 +360,13 @@ def main():
threshold_pct=args.threshold_backup_size_percentage, use_iam=args.use_iam, threshold_pct=args.threshold_backup_size_percentage, use_iam=args.use_iam,
no_leader=args.no_leader, retries=args.retries) no_leader=args.no_leader, retries=args.retries)
exit_code = restore.run() exit_code = restore.run()
if not exit_code == ExitCode.RETRY_LATER: # only WAL-E failures lead to the retry if exit_code != ExitCode.RETRY_LATER: # only WAL-E failures lead to the retry
logger.debug('exit_code is %r, not retrying', exit_code) logger.debug('exit_code is %r, not retrying', exit_code)
break break
time.sleep(RETRY_SLEEP_INTERVAL) time.sleep(RETRY_SLEEP_INTERVAL)
if TYPE_CHECKING: # pragma: no cover
assert exit_code is not None
return exit_code return exit_code
+646 -183
View File
File diff suppressed because it is too large Load Diff
+792 -144
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,4 +2,4 @@
:var __version__: the current Patroni version. :var __version__: the current Patroni version.
""" """
__version__ = '3.0.2' __version__ = '3.1.0'
+51 -45
View File
@@ -4,7 +4,10 @@ import platform
import sys import sys
from threading import RLock from threading import RLock
from patroni.exceptions import WatchdogError from typing import Any, Callable, Dict, Optional, Union
from ..config import Config
from ..exceptions import WatchdogError
__all__ = ['WatchdogError', 'Watchdog'] __all__ = ['WatchdogError', 'Watchdog']
@@ -15,10 +18,10 @@ MODE_AUTOMATIC = 'automatic' # Will use a watchdog if one is available
MODE_OFF = 'off' # Will not try to use a watchdog MODE_OFF = 'off' # Will not try to use a watchdog
def parse_mode(mode): def parse_mode(mode: Union[bool, str]) -> str:
if mode is False: if mode is False:
return MODE_OFF return MODE_OFF
mode = mode.lower() mode = str(mode).lower()
if mode in ['require', 'required']: if mode in ['require', 'required']:
return MODE_REQUIRED return MODE_REQUIRED
elif mode in ['auto', 'automatic']: elif mode in ['auto', 'automatic']:
@@ -29,33 +32,35 @@ def parse_mode(mode):
return MODE_OFF return MODE_OFF
def synchronized(func): def synchronized(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapped(self, *args, **kwargs): def wrapped(self: 'Watchdog', *args: Any, **kwargs: Any) -> Any:
with self._lock: with self.lock:
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
return wrapped return wrapped
class WatchdogConfig(object): class WatchdogConfig(object):
"""Helper to contain a snapshot of configuration""" """Helper to contain a snapshot of configuration"""
def __init__(self, config): def __init__(self, config: Config) -> None:
self.mode = parse_mode(config['watchdog'].get('mode', 'automatic')) watchdog_config = config.get("watchdog") or {'mode': 'automatic'}
self.mode = parse_mode(watchdog_config.get('mode', 'automatic'))
self.ttl = config['ttl'] self.ttl = config['ttl']
self.loop_wait = config['loop_wait'] self.loop_wait = config['loop_wait']
self.safety_margin = config['watchdog'].get('safety_margin', 5) self.safety_margin = watchdog_config.get('safety_margin', 5)
self.driver = config['watchdog'].get('driver', 'default') self.driver = watchdog_config.get('driver', 'default')
self.driver_config = dict((k, v) for k, v in config['watchdog'].items() self.driver_config = dict((k, v) for k, v in watchdog_config.items()
if k not in ['mode', 'safety_margin', 'driver']) if k not in ['mode', 'safety_margin', 'driver'])
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return isinstance(other, WatchdogConfig) and \ return isinstance(other, WatchdogConfig) and \
all(getattr(self, attr) == getattr(other, attr) for attr in all(getattr(self, attr) == getattr(other, attr) for attr in
['mode', 'ttl', 'loop_wait', 'safety_margin', 'driver', 'driver_config']) ['mode', 'ttl', 'loop_wait', 'safety_margin', 'driver', 'driver_config'])
def __ne__(self, other): def __ne__(self, other: Any) -> bool:
return not self == other return not self == other
def get_impl(self): def get_impl(self) -> 'WatchdogBase':
if self.driver == 'testing': # pragma: no cover if self.driver == 'testing': # pragma: no cover
from patroni.watchdog.linux import TestingWatchdogDevice from patroni.watchdog.linux import TestingWatchdogDevice
return TestingWatchdogDevice.from_config(self.driver_config) return TestingWatchdogDevice.from_config(self.driver_config)
@@ -66,14 +71,14 @@ class WatchdogConfig(object):
return NullWatchdog() return NullWatchdog()
@property @property
def timeout(self): def timeout(self) -> int:
if self.safety_margin == -1: if self.safety_margin == -1:
return int(self.ttl // 2) return int(self.ttl // 2)
else: else:
return self.ttl - self.safety_margin return self.ttl - self.safety_margin
@property @property
def timing_slack(self): def timing_slack(self) -> int:
return self.timeout - self.loop_wait return self.timeout - self.loop_wait
@@ -82,9 +87,10 @@ class Watchdog(object):
When activation fails underlying implementation will be switched to a Null implementation. To avoid log spam When activation fails underlying implementation will be switched to a Null implementation. To avoid log spam
activation will only be retried when watchdog configuration is changed.""" activation will only be retried when watchdog configuration is changed."""
def __init__(self, config): def __init__(self, config: Config) -> None:
self.active_config = self.config = WatchdogConfig(config) self.config = WatchdogConfig(config)
self._lock = RLock() self.active_config: WatchdogConfig = self.config
self.lock = RLock()
self.active = False self.active = False
if self.config.mode == MODE_OFF: if self.config.mode == MODE_OFF:
@@ -96,7 +102,7 @@ class Watchdog(object):
sys.exit(1) sys.exit(1)
@synchronized @synchronized
def reload_config(self, config): def reload_config(self, config: Config) -> None:
self.config = WatchdogConfig(config) self.config = WatchdogConfig(config)
# Turning a watchdog off can always be done immediately # Turning a watchdog off can always be done immediately
if self.config.mode == MODE_OFF: if self.config.mode == MODE_OFF:
@@ -113,7 +119,7 @@ class Watchdog(object):
self.active_config = self.config self.active_config = self.config
@synchronized @synchronized
def activate(self): def activate(self) -> bool:
"""Activates the watchdog device with suitable timeouts. While watchdog is active keepalive needs """Activates the watchdog device with suitable timeouts. While watchdog is active keepalive needs
to be called every time loop_wait expires. to be called every time loop_wait expires.
@@ -122,7 +128,7 @@ class Watchdog(object):
self.active = True self.active = True
return self._activate() return self._activate()
def _activate(self): def _activate(self) -> bool:
self.active_config = self.config self.active_config = self.config
if self.config.timing_slack < 0: if self.config.timing_slack < 0:
@@ -136,12 +142,13 @@ class Watchdog(object):
except WatchdogError as e: except WatchdogError as e:
logger.warning("Could not activate %s: %s", self.impl.describe(), e) logger.warning("Could not activate %s: %s", self.impl.describe(), e)
self.impl = NullWatchdog() self.impl = NullWatchdog()
actual_timeout = self.impl.get_timeout()
if self.impl.is_running and not self.impl.can_be_disabled: if self.impl.is_running and not self.impl.can_be_disabled:
logger.warning("Watchdog implementation can't be disabled." logger.warning("Watchdog implementation can't be disabled."
" Watchdog will trigger after Patroni loses leader key.") " Watchdog will trigger after Patroni loses leader key.")
if not self.impl.is_running or actual_timeout > self.config.timeout: if not self.impl.is_running or actual_timeout and actual_timeout > self.config.timeout:
if self.config.mode == MODE_REQUIRED: if self.config.mode == MODE_REQUIRED:
if self.impl.is_null: if self.impl.is_null:
logger.error("Configuration requires watchdog, but watchdog could not be configured.") logger.error("Configuration requires watchdog, but watchdog could not be configured.")
@@ -165,7 +172,7 @@ class Watchdog(object):
return True return True
def _set_timeout(self): def _set_timeout(self) -> Optional[int]:
if self.impl.has_set_timeout(): if self.impl.has_set_timeout():
self.impl.set_timeout(self.config.timeout) self.impl.set_timeout(self.config.timeout)
@@ -182,11 +189,11 @@ class Watchdog(object):
return actual_timeout return actual_timeout
@synchronized @synchronized
def disable(self): def disable(self) -> None:
self._disable() self._disable()
self.active = False self.active = False
def _disable(self): def _disable(self) -> None:
try: try:
if self.impl.is_running and not self.impl.can_be_disabled: if self.impl.is_running and not self.impl.can_be_disabled:
# Give sysadmin some extra time to clean stuff up. # Give sysadmin some extra time to clean stuff up.
@@ -198,7 +205,7 @@ class Watchdog(object):
logger.error("Error while disabling watchdog: %s", e) logger.error("Error while disabling watchdog: %s", e)
@synchronized @synchronized
def keepalive(self): def keepalive(self) -> None:
try: try:
if self.active: if self.active:
self.impl.keepalive() self.impl.keepalive()
@@ -223,12 +230,12 @@ class Watchdog(object):
@property @property
@synchronized @synchronized
def is_running(self): def is_running(self) -> bool:
return self.impl.is_running return self.impl.is_running
@property @property
@synchronized @synchronized
def is_healthy(self): def is_healthy(self) -> bool:
if self.config.mode != MODE_REQUIRED: if self.config.mode != MODE_REQUIRED:
return True return True
return self.config.timing_slack >= 0 and self.impl.is_healthy return self.config.timing_slack >= 0 and self.impl.is_healthy
@@ -240,60 +247,59 @@ class WatchdogBase(abc.ABC):
is_null = False is_null = False
@property @property
def is_running(self): def is_running(self) -> bool:
"""Returns True when watchdog is activated and capable of performing it's task.""" """Returns True when watchdog is activated and capable of performing it's task."""
return False return False
@property @property
def is_healthy(self): def is_healthy(self) -> bool:
"""Returns False when calling open() is known to fail.""" """Returns False when calling open() is known to fail."""
return False return False
@property @property
def can_be_disabled(self): def can_be_disabled(self) -> bool:
"""Returns True when watchdog will be disabled by calling close(). Some watchdog devices """Returns True when watchdog will be disabled by calling close(). Some watchdog devices
will keep running no matter what once activated. May raise WatchdogError if called without will keep running no matter what once activated. May raise WatchdogError if called without
calling open() first.""" calling open() first."""
return True return True
@abc.abstractmethod @abc.abstractmethod
def open(self): def open(self) -> None:
"""Open watchdog device. """Open watchdog device.
When watchdog is opened keepalive must be called. Returns nothing on success When watchdog is opened keepalive must be called. Returns nothing on success
or raises WatchdogError if the device could not be opened.""" or raises WatchdogError if the device could not be opened."""
@abc.abstractmethod @abc.abstractmethod
def close(self): def close(self) -> None:
"""Gracefully close watchdog device.""" """Gracefully close watchdog device."""
@abc.abstractmethod @abc.abstractmethod
def keepalive(self): def keepalive(self) -> None:
"""Resets the watchdog timer. """Resets the watchdog timer.
Watchdog must be open when keepalive is called.""" Watchdog must be open when keepalive is called."""
@abc.abstractmethod @abc.abstractmethod
def get_timeout(self): def get_timeout(self) -> int:
"""Returns the current keepalive timeout in effect.""" """Returns the current keepalive timeout in effect."""
@staticmethod def has_set_timeout(self) -> bool:
def has_set_timeout():
"""Returns True if setting a timeout is supported.""" """Returns True if setting a timeout is supported."""
return False return False
def set_timeout(self, timeout): def set_timeout(self, timeout: int) -> None:
"""Set the watchdog timer timeout. """Set the watchdog timer timeout.
:param timeout: watchdog timeout in seconds""" :param timeout: watchdog timeout in seconds"""
raise WatchdogError("Setting timeout is not supported on {0}".format(self.describe())) raise WatchdogError("Setting timeout is not supported on {0}".format(self.describe()))
def describe(self): def describe(self) -> str:
"""Human readable name for this device""" """Human readable name for this device"""
return self.__class__.__name__ return self.__class__.__name__
@classmethod @classmethod
def from_config(cls, config): def from_config(cls, config: Dict[str, Any]) -> 'WatchdogBase':
return cls() return cls()
@@ -301,15 +307,15 @@ class NullWatchdog(WatchdogBase):
"""Null implementation when watchdog is not supported.""" """Null implementation when watchdog is not supported."""
is_null = True is_null = True
def open(self): def open(self) -> None:
return return
def close(self): def close(self) -> None:
return return
def keepalive(self): def keepalive(self) -> None:
return return
def get_timeout(self): def get_timeout(self) -> int:
# A big enough number to not matter # A big enough number to not matter
return 1000000000 return 1000000000
+38 -27
View File
@@ -1,8 +1,11 @@
import collections # pyright: reportConstantRedefinition=false
import ctypes import ctypes
import os import os
import platform import platform
from patroni.watchdog.base import WatchdogBase, WatchdogError
from typing import Any, Dict, NamedTuple
from .base import WatchdogBase, WatchdogError
# Pythonification of linux/ioctl.h # Pythonification of linux/ioctl.h
IOC_NONE = 0 IOC_NONE = 0
@@ -19,7 +22,7 @@ machine = platform.machine()
if machine in ['mips', 'sparc', 'powerpc', 'ppc64', 'ppc64le']: # pragma: no cover if machine in ['mips', 'sparc', 'powerpc', 'ppc64', 'ppc64le']: # pragma: no cover
IOC_SIZEBITS = 13 IOC_SIZEBITS = 13
IOC_DIRBITS = 3 IOC_DIRBITS = 3
IOC_NONE, IOC_WRITE, IOC_READ = 1, 4, 2 IOC_NONE, IOC_WRITE = 1, 4
elif machine == 'parisc': # pragma: no cover elif machine == 'parisc': # pragma: no cover
IOC_WRITE, IOC_READ = 2, 1 IOC_WRITE, IOC_READ = 2, 1
@@ -29,19 +32,19 @@ IOC_SIZESHIFT = IOC_TYPESHIFT + IOC_TYPEBITS
IOC_DIRSHIFT = IOC_SIZESHIFT + IOC_SIZEBITS IOC_DIRSHIFT = IOC_SIZESHIFT + IOC_SIZEBITS
def IOW(type_, nr, size): def IOW(type_: str, nr: int, size: int) -> int:
return IOC(IOC_WRITE, type_, nr, size) return IOC(IOC_WRITE, type_, nr, size)
def IOR(type_, nr, size): def IOR(type_: str, nr: int, size: int) -> int:
return IOC(IOC_READ, type_, nr, size) return IOC(IOC_READ, type_, nr, size)
def IOWR(type_, nr, size): def IOWR(type_: str, nr: int, size: int) -> int:
return IOC(IOC_READ | IOC_WRITE, type_, nr, size) return IOC(IOC_READ | IOC_WRITE, type_, nr, size)
def IOC(dir_, type_, nr, size): def IOC(dir_: int, type_: str, nr: int, size: int) -> int:
return (dir_ << IOC_DIRSHIFT) \ return (dir_ << IOC_DIRSHIFT) \
| (ord(type_) << IOC_TYPESHIFT) \ | (ord(type_) << IOC_TYPESHIFT) \
| (nr << IOC_NRSHIFT) \ | (nr << IOC_NRSHIFT) \
@@ -104,9 +107,13 @@ WDIOS = {
# Implementation # Implementation
class WatchdogInfo(collections.namedtuple('WatchdogInfo', 'options,version,identity')): class WatchdogInfo(NamedTuple):
"""Watchdog descriptor from the kernel""" """Watchdog descriptor from the kernel"""
def __getattr__(self, name): options: int
version: int
identity: str
def __getattr__(self, name: str) -> bool:
"""Convenience has_XYZ attributes for checking WDIOF bits in options""" """Convenience has_XYZ attributes for checking WDIOF bits in options"""
if name.startswith('has_') and name[4:] in WDIOF: if name.startswith('has_') and name[4:] in WDIOF:
return bool(self.options & WDIOF[name[4:]]) return bool(self.options & WDIOF[name[4:]])
@@ -117,32 +124,32 @@ class WatchdogInfo(collections.namedtuple('WatchdogInfo', 'options,version,ident
class LinuxWatchdogDevice(WatchdogBase): class LinuxWatchdogDevice(WatchdogBase):
DEFAULT_DEVICE = '/dev/watchdog' DEFAULT_DEVICE = '/dev/watchdog'
def __init__(self, device): def __init__(self, device: str) -> None:
self.device = device self.device = device
self._support_cache = None self._support_cache = None
self._fd = None self._fd = None
@classmethod @classmethod
def from_config(cls, config): def from_config(cls, config: Dict[str, Any]) -> 'LinuxWatchdogDevice':
device = config.get('device', cls.DEFAULT_DEVICE) device = config.get('device', cls.DEFAULT_DEVICE)
return cls(device) return cls(device)
@property @property
def is_running(self): def is_running(self) -> bool:
return self._fd is not None return self._fd is not None
@property @property
def is_healthy(self): def is_healthy(self) -> bool:
return os.path.exists(self.device) and os.access(self.device, os.W_OK) return os.path.exists(self.device) and os.access(self.device, os.W_OK)
def open(self): def open(self) -> None:
try: try:
self._fd = os.open(self.device, os.O_WRONLY) self._fd = os.open(self.device, os.O_WRONLY)
except OSError as e: except OSError as e:
raise WatchdogError("Can't open watchdog device: {0}".format(e)) raise WatchdogError("Can't open watchdog device: {0}".format(e))
def close(self): def close(self) -> None:
if self.is_running: if self._fd is not None: # self.is_running
try: try:
os.write(self._fd, b'V') os.write(self._fd, b'V')
os.close(self._fd) os.close(self._fd)
@@ -151,10 +158,10 @@ class LinuxWatchdogDevice(WatchdogBase):
raise WatchdogError("Error while closing {0}: {1}".format(self.describe(), e)) raise WatchdogError("Error while closing {0}: {1}".format(self.describe(), e))
@property @property
def can_be_disabled(self): def can_be_disabled(self) -> bool:
return self.get_support().has_MAGICCLOSE return self.get_support().has_MAGICCLOSE
def _ioctl(self, func, arg): def _ioctl(self, func: int, arg: Any) -> None:
"""Runs the specified ioctl on the underlying fd. """Runs the specified ioctl on the underlying fd.
Raises WatchdogError if the device is closed. Raises WatchdogError if the device is closed.
@@ -165,7 +172,7 @@ class LinuxWatchdogDevice(WatchdogBase):
import fcntl import fcntl
fcntl.ioctl(self._fd, func, arg, True) fcntl.ioctl(self._fd, func, arg, True)
def get_support(self): def get_support(self) -> WatchdogInfo:
if self._support_cache is None: if self._support_cache is None:
info = watchdog_info() info = watchdog_info()
try: try:
@@ -177,7 +184,7 @@ class LinuxWatchdogDevice(WatchdogBase):
bytearray(info.identity).decode(errors='ignore').rstrip('\x00')) bytearray(info.identity).decode(errors='ignore').rstrip('\x00'))
return self._support_cache return self._support_cache
def describe(self): def describe(self) -> str:
dev_str = " at {0}".format(self.device) if self.device != self.DEFAULT_DEVICE else "" dev_str = " at {0}".format(self.device) if self.device != self.DEFAULT_DEVICE else ""
ver_str = "" ver_str = ""
identity = "Linux watchdog device" identity = "Linux watchdog device"
@@ -190,17 +197,19 @@ class LinuxWatchdogDevice(WatchdogBase):
return identity + ver_str + dev_str return identity + ver_str + dev_str
def keepalive(self): def keepalive(self) -> None:
if self._fd is None:
raise WatchdogError("Watchdog device is closed")
try: try:
os.write(self._fd, b'1') os.write(self._fd, b'1')
except OSError as e: except OSError as e:
raise WatchdogError("Could not send watchdog keepalive: {0}".format(e)) raise WatchdogError("Could not send watchdog keepalive: {0}".format(e))
def has_set_timeout(self): def has_set_timeout(self) -> bool:
"""Returns True if setting a timeout is supported.""" """Returns True if setting a timeout is supported."""
return self.get_support().has_SETTIMEOUT return self.get_support().has_SETTIMEOUT
def set_timeout(self, timeout): def set_timeout(self, timeout: int) -> None:
timeout = int(timeout) timeout = int(timeout)
if not 0 < timeout < 0xFFFF: if not 0 < timeout < 0xFFFF:
raise WatchdogError("Invalid timeout {0}. Supported values are between 1 and 65535".format(timeout)) raise WatchdogError("Invalid timeout {0}. Supported values are between 1 and 65535".format(timeout))
@@ -209,7 +218,7 @@ class LinuxWatchdogDevice(WatchdogBase):
except (WatchdogError, OSError, IOError) as e: except (WatchdogError, OSError, IOError) as e:
raise WatchdogError("Could not set timeout on watchdog device: {}".format(e)) raise WatchdogError("Could not set timeout on watchdog device: {}".format(e))
def get_timeout(self): def get_timeout(self) -> int:
timeout = ctypes.c_int() timeout = ctypes.c_int()
try: try:
self._ioctl(WDIOC_GETTIMEOUT, timeout) self._ioctl(WDIOC_GETTIMEOUT, timeout)
@@ -222,14 +231,16 @@ class TestingWatchdogDevice(LinuxWatchdogDevice): # pragma: no cover
"""Converts timeout ioctls to regular writes that can be intercepted from a named pipe.""" """Converts timeout ioctls to regular writes that can be intercepted from a named pipe."""
timeout = 60 timeout = 60
def get_support(self): def get_support(self) -> WatchdogInfo:
return WatchdogInfo(WDIOF['MAGICCLOSE'] | WDIOF['SETTIMEOUT'], 0, "Watchdog test harness") return WatchdogInfo(WDIOF['MAGICCLOSE'] | WDIOF['SETTIMEOUT'], 0, "Watchdog test harness")
def set_timeout(self, timeout): def set_timeout(self, timeout: int) -> None:
if self._fd is None:
raise WatchdogError("Watchdog device is closed")
buf = "Ctimeout={0}\n".format(timeout).encode('utf8') buf = "Ctimeout={0}\n".format(timeout).encode('utf8')
while len(buf): while len(buf):
buf = buf[os.write(self._fd, buf):] buf = buf[os.write(self._fd, buf):]
self.timeout = timeout self.timeout = timeout
def get_timeout(self): def get_timeout(self) -> int:
return self.timeout return self.timeout
+8 -9
View File
@@ -59,6 +59,13 @@ bootstrap:
#primary_slot_name: patroni #primary_slot_name: patroni
postgresql: postgresql:
use_pg_rewind: true use_pg_rewind: true
pg_hba:
# For kerberos gss based connectivity (discard @.*$)
#- host replication replicator 127.0.0.1/32 gss include_realm=0
#- host all all 0.0.0.0/0 gss include_realm=0
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# use_slots: true # use_slots: true
parameters: parameters:
# wal_level: hot_standby # wal_level: hot_standby
@@ -83,18 +90,10 @@ bootstrap:
- encoding: UTF8 - encoding: UTF8
- data-checksums - data-checksums
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
# For kerberos gss based connectivity (discard @.*$)
#- host replication replicator 127.0.0.1/32 gss include_realm=0
#- host all all 0.0.0.0/0 gss include_realm=0
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter) # Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
# post_init: /usr/local/bin/setup_cluster.sh # post_init: /usr/local/bin/setup_cluster.sh
# Some additional users users which needs to be created after initializing new cluster # Some additional users which needs to be created after initializing new cluster
users: users:
admin: admin:
password: admin% password: admin%
+8 -9
View File
@@ -53,6 +53,13 @@ bootstrap:
maximum_lag_on_failover: 1048576 maximum_lag_on_failover: 1048576
postgresql: postgresql:
use_pg_rewind: true use_pg_rewind: true
pg_hba:
# For kerberos gss based connectivity (discard @.*$)
#- host replication replicator 127.0.0.1/32 gss include_realm=0
#- host all all 0.0.0.0/0 gss include_realm=0
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# use_slots: true # use_slots: true
parameters: parameters:
# wal_level: hot_standby # wal_level: hot_standby
@@ -77,18 +84,10 @@ bootstrap:
- encoding: UTF8 - encoding: UTF8
- data-checksums - data-checksums
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
# For kerberos gss based connectivity (discard @.*$)
#- host replication replicator 127.0.0.1/32 gss include_realm=0
#- host all all 0.0.0.0/0 gss include_realm=0
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter) # Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
# post_init: /usr/local/bin/setup_cluster.sh # post_init: /usr/local/bin/setup_cluster.sh
# Some additional users users which needs to be created after initializing new cluster # Some additional users which needs to be created after initializing new cluster
users: users:
admin: admin:
password: admin% password: admin%
+9 -10
View File
@@ -53,6 +53,13 @@ bootstrap:
maximum_lag_on_failover: 1048576 maximum_lag_on_failover: 1048576
postgresql: postgresql:
use_pg_rewind: true use_pg_rewind: true
pg_hba:
# For kerberos gss based connectivity (discard @.*$)
#- host replication replicator 127.0.0.1/32 gss include_realm=0
#- host all all 0.0.0.0/0 gss include_realm=0
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# use_slots: true # use_slots: true
parameters: parameters:
# wal_level: hot_standby # wal_level: hot_standby
@@ -77,15 +84,7 @@ bootstrap:
- encoding: UTF8 - encoding: UTF8
- data-checksums - data-checksums
pg_hba: # Add following lines to pg_hba.conf after running 'initdb' # Some additional users which needs to be created after initializing new cluster
# For kerberos gss based connectivity (discard @.*$)
#- host replication replicator 127.0.0.1/32 gss include_realm=0
#- host all all 0.0.0.0/0 gss include_realm=0
- host replication replicator 127.0.0.1/32 md5
- host all all 0.0.0.0/0 md5
# - hostssl all all 0.0.0.0/0 md5
# Some additional users users which needs to be created after initializing new cluster
users: users:
admin: admin:
password: admin% password: admin%
@@ -122,4 +121,4 @@ tags:
nofailover: false nofailover: false
noloadbalance: false noloadbalance: false
clonefrom: false clonefrom: false
replicatefrom: postgres1 # replicatefrom: postgresql1
+27
View File
@@ -0,0 +1,27 @@
{
"include": [
"patroni"
],
"exclude": [
"**/__pycache__"
],
"ignore": [
],
"defineConstant": {
"DEBUG": true
},
"stubPath": "typings/",
"reportMissingImports": true,
"reportMissingTypeStubs": false,
"pythonVersion": "3.11",
"pythonPlatform": "All",
"typeCheckingMode": "strict"
}
+6 -5
View File
@@ -1,11 +1,12 @@
#!/bin/bash #!/bin/bash
# Release process: # Release process:
# 1. Open a PR that updates release notes and Patroni version # 1. Open a PR that updates release notes, Patroni version and pyright version in the tests workflow.
# 2. Merge it # 2. Resolve possible typing issues.
# 3. Run release.sh # 3. Merge the PR.
# 4. After the new tag is pushed, the .github/workflows/release.yaml will run tests and upload the new package to test.pypi.org # 4. Run release.sh
# 5. Once the release is created, the .github/workflows/release.yaml will run tests and upload the new package to pypi.org # 5. After the new tag is pushed, the .github/workflows/release.yaml will run tests and upload the new package to test.pypi.org
# 6. Once the release is created, the .github/workflows/release.yaml will run tests and upload the new package to pypi.org
## Bail out on any non-zero exitcode from the called processes ## Bail out on any non-zero exitcode from the called processes
set -xe set -xe
+6 -3
View File
@@ -88,7 +88,7 @@ class Flake8(_Command):
yield package_directory yield package_directory
def targets(self): def targets(self):
return [package for package in self.package_files()] + ['tests', 'setup.py'] return [package for package in self.package_files()] + ['tests', 'features', 'setup.py']
def run(self): def run(self):
from flake8.main.cli import main from flake8.main.cli import main
@@ -116,7 +116,7 @@ class PyTest(_Command):
def read(fname): def read(fname):
with open(os.path.join(__location__, fname)) as fd: with open(os.path.join(__location__, fname), encoding='utf-8') as fd:
return fd.read() return fd.read()
@@ -157,7 +157,10 @@ def setup_package(version):
long_description=read('README.rst'), long_description=read('README.rst'),
classifiers=CLASSIFIERS, classifiers=CLASSIFIERS,
packages=find_packages(exclude=['tests', 'tests.*']), packages=find_packages(exclude=['tests', 'tests.*']),
package_data={MAIN_PACKAGE: ["*.json"]}, package_data={MAIN_PACKAGE: [
"postgresql/available_parameters/*.yml",
"postgresql/available_parameters/*.yaml",
]},
install_requires=install_requires, install_requires=install_requires,
extras_require=EXTRAS_REQUIRE, extras_require=EXTRAS_REQUIRE,
cmdclass=cmdclass, cmdclass=cmdclass,
+16 -8
View File
@@ -3,7 +3,7 @@ import os
import shutil import shutil
import unittest import unittest
from mock import Mock, patch from mock import Mock, PropertyMock, patch
import urllib3 import urllib3
@@ -19,10 +19,20 @@ class SleepException(Exception):
pass pass
mock_available_gucs = PropertyMock(return_value={
'cluster_name', 'constraint_exclusion', 'force_parallel_mode', 'hot_standby', 'listen_addresses', 'max_connections',
'max_locks_per_transaction', 'max_prepared_transactions', 'max_replication_slots', 'max_stack_depth',
'max_wal_senders', 'max_worker_processes', 'port', 'search_path', 'shared_preload_libraries',
'stats_temp_directory', 'synchronous_standby_names', 'track_commit_timestamp', 'unix_socket_directories',
'vacuum_cost_delay', 'vacuum_cost_limit', 'wal_keep_size', 'wal_level', 'wal_log_hints', 'zero_damaged_pages',
})
class MockResponse(object): class MockResponse(object):
def __init__(self, status_code=200): def __init__(self, status_code=200):
self.status_code = status_code self.status_code = status_code
self.headers = {'content-type': 'json'}
self.content = '{}' self.content = '{}'
self.reason = 'Not Found' self.reason = 'Not Found'
@@ -38,10 +48,6 @@ class MockResponse(object):
def getheader(*args): def getheader(*args):
return '' return ''
@staticmethod
def getheaders():
return {'content-type': 'json'}
def requests_get(url, method='GET', endpoint=None, data='', **kwargs): def requests_get(url, method='GET', endpoint=None, data='', **kwargs):
members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\ members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\
@@ -85,6 +91,8 @@ class MockCursor(object):
self.description = [Mock()] self.description = [Mock()]
def execute(self, sql, *params): def execute(self, sql, *params):
if isinstance(sql, bytes):
sql = sql.decode('utf-8')
if sql.startswith('blabla'): if sql.startswith('blabla'):
raise psycopg.ProgrammingError() raise psycopg.ProgrammingError()
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'): elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
@@ -98,9 +106,9 @@ class MockCursor(object):
elif sql.startswith('SELECT slot_name'): elif sql.startswith('SELECT slot_name'):
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b', 5, 100, 500)] self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b', 5, 100, 500)]
elif sql.startswith('WITH slots AS (SELECT slot_name, active'): elif sql.startswith('WITH slots AS (SELECT slot_name, active'):
self.results = [(False, True)] self.results = [(False, True)] if self.rowcount == 1 else [None]
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, 'streaming', '',
[{"slot_name": "ls", "confirmed_flush_lsn": 12345}], [{"slot_name": "ls", "confirmed_flush_lsn": 12345}],
'on', 'n1', None)] 'on', 'n1', None)]
elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'): elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'):
@@ -109,7 +117,7 @@ class MockCursor(object):
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}]'
now = datetime.datetime.now(tzutc) now = datetime.datetime.now(tzutc)
self.results = [(now, 0, '', 0, '', False, now, replication_info)] self.results = [(now, 0, '', 0, '', False, now, 'streaming', None, 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'),
+60 -42
View File
@@ -11,6 +11,7 @@ from mock import Mock, PropertyMock, patch
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from patroni.api import RestApiHandler, RestApiServer from patroni.api import RestApiHandler, RestApiServer
from patroni.config import GlobalConfig
from patroni.dcs import ClusterConfig, Member from patroni.dcs import ClusterConfig, Member
from patroni.ha import _MemberStatus from patroni.ha import _MemberStatus
from patroni.utils import tzutc from patroni.utils import tzutc
@@ -28,7 +29,8 @@ class MockPostgresql(object):
name = 'test' name = 'test'
state = 'running' state = 'running'
role = 'primary' role = 'primary'
server_version = '999999' server_version = 90625
major_version = 90600
sysid = 'dummysysid' sysid = 'dummysysid'
scope = 'dummy' scope = 'dummy'
pending_restart = True pending_restart = True
@@ -54,6 +56,10 @@ class MockPostgresql(object):
def is_running(): def is_running():
return True return True
@staticmethod
def replication_state_from_parameters(*args):
return 'streaming'
class MockWatchdog(object): class MockWatchdog(object):
is_healthy = False is_healthy = False
@@ -116,10 +122,6 @@ class MockHa(object):
def is_paused(): def is_paused():
return True return True
@staticmethod
def is_standby_cluster():
return False
class MockLogger(object): class MockLogger(object):
@@ -128,10 +130,16 @@ class MockLogger(object):
records_lost = 1 records_lost = 1
class MockConfig(object):
def get_global_config(self, _):
return GlobalConfig({})
class MockPatroni(object): class MockPatroni(object):
ha = MockHa() ha = MockHa()
config = Mock() config = MockConfig()
postgresql = ha.state_handler postgresql = ha.state_handler
dcs = Mock() dcs = Mock()
logger = MockLogger() logger = MockLogger()
@@ -185,7 +193,8 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_GET(self): def test_do_GET(self):
MockPatroni.dcs.cluster.last_lsn = 20 MockPatroni.dcs.cluster.last_lsn = 20
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name] MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
MockRestApiServer(RestApiHandler, 'GET /replica') with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M') MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB') MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10485760') MockRestApiServer(RestApiHandler, 'GET /replica?lag=10485760')
@@ -207,7 +216,7 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(MockHa, 'is_leader', Mock(return_value=True)): with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /replica') MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /read-only-sync') MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)): with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader') MockRestApiServer(RestApiHandler, 'GET /standby_leader')
MockPatroni.dcs.cluster = None MockPatroni.dcs.cluster = None
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'primary'})): with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'primary'})):
@@ -215,9 +224,10 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)): with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /primary') MockRestApiServer(RestApiHandler, 'GET /primary')
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary'))
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, '')])): with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)): with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)), \
patch.object(GlobalConfig, 'is_paused', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader') MockRestApiServer(RestApiHandler, 'GET /standby_leader')
# test tags # test tags
@@ -393,8 +403,8 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(MockHa, 'is_failsafe_mode', Mock(return_value=False), create=True): with patch.object(MockHa, 'is_failsafe_mode', Mock(return_value=False), create=True):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /failsafe HTTP/1.0' + self._authorization)) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /failsafe HTTP/1.0' + self._authorization))
with patch.object(MockHa, 'is_failsafe_mode', Mock(return_value=True), create=True): with patch.object(MockHa, 'is_failsafe_mode', Mock(return_value=True), create=True):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /failsafe HTTP/1.0' + self._authorization + self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /failsafe HTTP/1.0' + self._authorization
'\nContent-Length: 9\n\n{"a":"b"}')) + '\nContent-Length: 9\n\n{"a":"b"}'))
@patch.object(MockPatroni, 'sighup_handler', Mock()) @patch.object(MockPatroni, 'sighup_handler', Mock())
def test_do_POST_reload(self): def test_do_POST_reload(self):
@@ -405,9 +415,7 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_POST_sigterm(self): def test_do_POST_sigterm(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /sigterm HTTP/1.0' + self._authorization)) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /sigterm HTTP/1.0' + self._authorization))
@patch.object(MockPatroni, 'dcs') def test_do_POST_restart(self):
def test_do_POST_restart(self, mock_dcs):
mock_dcs.get_cluster.return_value.is_paused.return_value = False
request = 'POST /restart HTTP/1.0' + self._authorization request = 'POST /restart HTTP/1.0' + self._authorization
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request)) self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
@@ -449,12 +457,12 @@ class TestRestApiHandler(unittest.TestCase):
request = make_request(role='primary', postgres_version='9.5.2') request = make_request(role='primary', postgres_version='9.5.2')
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
mock_dcs.get_cluster.return_value.is_paused.return_value = True with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='primary')) MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='primary'))
# Valid timeout # Valid timeout
MockRestApiServer(RestApiHandler, make_request(timeout='60s')) MockRestApiServer(RestApiHandler, make_request(timeout='60s'))
# Invalid timeout # Invalid timeout
MockRestApiServer(RestApiHandler, make_request(timeout='42towels')) MockRestApiServer(RestApiHandler, make_request(timeout='42towels'))
def test_do_DELETE_restart(self): def test_do_DELETE_restart(self):
for retval in (True, False): for retval in (True, False):
@@ -471,10 +479,7 @@ class TestRestApiHandler(unittest.TestCase):
mock_dcs.get_cluster.return_value.failover = None mock_dcs.get_cluster.return_value.failover = None
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request)) self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
@patch.object(MockPatroni, 'dcs') def test_do_POST_reinitialize(self):
def test_do_POST_reinitialize(self, mock_dcs):
cluster = mock_dcs.get_cluster.return_value
cluster.is_paused.return_value = False
request = 'POST /reinitialize HTTP/1.0' + self._authorization + '\nContent-Length: 15\n\n{"force": true}' request = 'POST /reinitialize HTTP/1.0' + self._authorization + '\nContent-Length: 15\n\n{"force": true}'
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'reinitialize', Mock(return_value=None)): with patch.object(MockHa, 'reinitialize', Mock(return_value=None)):
@@ -492,8 +497,6 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_POST_switchover(self, dcs): def test_do_POST_switchover(self, dcs):
dcs.loop_wait = 10 dcs.loop_wait = 10
cluster = dcs.get_cluster.return_value cluster = dcs.get_cluster.return_value
cluster.is_synchronous_mode.return_value = False
cluster.is_paused.return_value = False
post = 'POST /switchover HTTP/1.0' + self._authorization + '\nContent-Length: ' post = 'POST /switchover HTTP/1.0' + self._authorization + '\nContent-Length: '
@@ -507,20 +510,22 @@ class TestRestApiHandler(unittest.TestCase):
request = post + '25\n\n{"leader": "postgresql1"}' request = post + '25\n\n{"leader": "postgresql1"}'
cluster.is_paused.return_value = True with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
MockRestApiServer(RestApiHandler, request)
cluster.is_paused.return_value = False
for cluster.is_synchronous_mode.return_value in (True, False):
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
for is_synchronous_mode in (True, False):
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)):
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql2' cluster.leader.name = 'postgresql2'
request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}' request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}'
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'postgresql1' cluster.leader.name = 'postgresql1'
for cluster.is_synchronous_mode.return_value in (True, False): cluster.sync.matches.return_value = False
MockRestApiServer(RestApiHandler, request) for is_synchronous_mode in (True, False):
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)):
MockRestApiServer(RestApiHandler, request)
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}), cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}),
Member(0, 'postgresql2', 30, {'api_url': 'http'})] Member(0, 'postgresql2', 30, {'api_url': 'http'})]
@@ -554,7 +559,8 @@ class TestRestApiHandler(unittest.TestCase):
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\ request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}' ' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}'
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
with patch.object(MockPatroni, 'dcs') as d: with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)), \
patch.object(MockPatroni, 'dcs') as d:
d.manual_failover.return_value = False d.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request) MockRestApiServer(RestApiHandler, request)
@@ -570,13 +576,11 @@ class TestRestApiHandler(unittest.TestCase):
# Invalid date # Invalid date
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}'))
@patch.object(MockPatroni, 'dcs', Mock())
def test_do_POST_failover(self): def test_do_POST_failover(self):
post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: ' post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}') MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}') MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}')
@patch.object(MockPatroni, 'dcs', Mock())
@patch.object(MockHa, 'is_leader', Mock(return_value=True)) @patch.object(MockHa, 'is_leader', Mock(return_value=True))
def test_do_POST_citus(self): def test_do_POST_citus(self):
post = 'POST /citus HTTP/1.0' + self._authorization + '\nContent-Length: ' post = 'POST /citus HTTP/1.0' + self._authorization + '\nContent-Length: '
@@ -622,24 +626,38 @@ class TestRestApiServer(unittest.TestCase):
try: try:
raise Exception() raise Exception()
except Exception: except Exception:
self.assertIsNone(MockRestApiServer.handle_error(None, ('127.0.0.1', 55555))) self.assertIsNone(self.srv.handle_error(None, ('127.0.0.1', 55555)))
@patch.object(HTTPServer, '__init__', Mock(side_effect=socket.error)) @patch.object(HTTPServer, '__init__', Mock(side_effect=socket.error))
def test_socket_error(self): def test_socket_error(self):
self.assertRaises(socket.error, MockRestApiServer, Mock(), '', {'listen': '*:8008'}) self.assertRaises(socket.error, MockRestApiServer, Mock(), '', {'listen': '*:8008'})
def __create_socket(self):
sock = socket.socket()
try:
import ssl
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
ctx.check_hostname = False
sock = ctx.wrap_socket(sock=sock)
sock.do_handshake = Mock()
sock.unwrap = Mock(side_effect=Exception)
except Exception:
pass
return sock
@patch.object(ThreadingMixIn, 'process_request_thread', Mock()) @patch.object(ThreadingMixIn, 'process_request_thread', Mock())
def test_process_request_thread(self): def test_process_request_thread(self):
self.srv.process_request_thread(Mock(), '2') self.srv.process_request_thread(self.__create_socket(), ('2', 54321))
@patch.object(MockRestApiServer, 'process_request', Mock(side_effect=RuntimeError)) @patch.object(MockRestApiServer, 'process_request', Mock(side_effect=RuntimeError))
@patch.object(MockRestApiServer, 'get_request') @patch.object(MockRestApiServer, 'get_request')
def test_process_request_error(self, mock_get_request): def test_process_request_error(self, mock_get_request):
mock_request = Mock() mock_get_request.return_value = (self.__create_socket(), ('127.0.0.1', 55555))
mock_request.unwrap.side_effect = Exception
mock_get_request.return_value = (mock_request, ('127.0.0.1', 55555))
self.srv._handle_request_noblock() self.srv._handle_request_noblock()
@patch('ssl._ssl._test_decode_cert', Mock()) @patch('ssl._ssl._test_decode_cert', Mock())
def test_reload_local_certificate(self): def test_reload_local_certificate(self):
self.assertTrue(self.srv.reload_local_certificate()) self.assertTrue(self.srv.reload_local_certificate())
def test_get_certificate_serial_number(self):
self.assertIsNone(self.srv.get_certificate_serial_number())
+55 -11
View File
@@ -1,4 +1,5 @@
import os import os
import sys
from mock import Mock, PropertyMock, patch from mock import Mock, PropertyMock, patch
@@ -8,12 +9,13 @@ from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.cancellable import CancellableSubprocess from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.config import ConfigHandler from patroni.postgresql.config import ConfigHandler
from . import psycopg_connect, BaseTestPostgresql from . import psycopg_connect, BaseTestPostgresql, mock_available_gucs
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('patroni.psycopg.connect', psycopg_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch('os.rename', Mock()) @patch('os.rename', Mock())
@patch.object(Postgresql, 'available_gucs', mock_available_gucs)
class TestBootstrap(BaseTestPostgresql): class TestBootstrap(BaseTestPostgresql):
@patch('patroni.postgresql.CallbackExecutor', Mock()) @patch('patroni.postgresql.CallbackExecutor', Mock())
@@ -99,6 +101,48 @@ class TestBootstrap(BaseTestPostgresql):
self.assertRaises(Exception, self.b.bootstrap, {'initdb': [1]}) self.assertRaises(Exception, self.b.bootstrap, {'initdb': [1]})
self.assertRaises(Exception, self.b.bootstrap, {'initdb': 1}) self.assertRaises(Exception, self.b.bootstrap, {'initdb': 1})
def test__process_user_options(self):
def error_handler(msg):
raise Exception(msg)
self.assertEqual(self.b.process_user_options('initdb', ['string'], (), error_handler), ['--string'])
self.assertEqual(
self.b.process_user_options(
'initdb',
[{'key': 'value'}],
(), error_handler
),
['--key=value'])
if sys.platform != 'win32':
self.assertEqual(
self.b.process_user_options(
'initdb',
[{'key': 'value with spaces'}],
(), error_handler
),
["--key=value with spaces"])
self.assertEqual(
self.b.process_user_options(
'initdb',
[{'key': "'value with spaces'"}],
(), error_handler
),
["--key=value with spaces"])
self.assertEqual(
self.b.process_user_options(
'initdb',
{'key': 'value with spaces'},
(), error_handler
),
["--key=value with spaces"])
self.assertEqual(
self.b.process_user_options(
'initdb',
{'key': "'value with spaces'"},
(), error_handler
),
["--key=value with spaces"])
@patch.object(CancellableSubprocess, 'call', Mock()) @patch.object(CancellableSubprocess, 'call', Mock())
@patch.object(Postgresql, 'is_running', Mock(return_value=True)) @patch.object(Postgresql, 'is_running', Mock(return_value=True))
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False)) @patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
@@ -111,9 +155,9 @@ class TestBootstrap(BaseTestPostgresql):
config = {'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}}} config = {'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}}}
with patch.object(Postgresql, 'is_running', Mock(return_value=False)),\ with patch.object(Postgresql, 'is_running', Mock(return_value=False)), \
patch.object(Postgresql, 'get_major_version', Mock(return_value=140000)),\ patch.object(Postgresql, 'get_major_version', Mock(return_value=140000)), \
patch('multiprocessing.Process', Mock(side_effect=Exception)),\ patch('multiprocessing.Process', Mock(side_effect=Exception)), \
patch('multiprocessing.get_context', Mock(side_effect=Exception), create=True): patch('multiprocessing.get_context', Mock(side_effect=Exception), create=True):
self.assertRaises(Exception, self.b.bootstrap, config) self.assertRaises(Exception, self.b.bootstrap, config)
with open(os.path.join(self.p.data_dir, 'pg_hba.conf')) as f: with open(os.path.join(self.p.data_dir, 'pg_hba.conf')) as f:
@@ -132,7 +176,7 @@ class TestBootstrap(BaseTestPostgresql):
@patch.object(CancellableSubprocess, 'call') @patch.object(CancellableSubprocess, 'call')
@patch.object(Postgresql, 'get_major_version', Mock(return_value=90600)) @patch.object(Postgresql, 'get_major_version', Mock(return_value=90600))
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'in production'})) @patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'in production'}))
def test_custom_bootstrap(self, mock_cancellable_subprocess_call): def test_custom_bootstrap(self, mock_cancellable_subprocess_call):
self.p.config._config.pop('pg_hba') self.p.config._config.pop('pg_hba')
config = {'method': 'foo', 'foo': {'command': 'bar'}} config = {'method': 'foo', 'foo': {'command': 'bar'}}
@@ -141,12 +185,12 @@ class TestBootstrap(BaseTestPostgresql):
self.assertFalse(self.b.bootstrap(config)) self.assertFalse(self.b.bootstrap(config))
mock_cancellable_subprocess_call.return_value = 0 mock_cancellable_subprocess_call.return_value = 0
with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))),\ with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))), \
patch('multiprocessing.get_context', Mock(side_effect=Exception("42")), create=True),\ patch('multiprocessing.get_context', Mock(side_effect=Exception("42")), create=True), \
patch('os.path.isfile', Mock(return_value=True)),\ patch('os.path.isfile', Mock(return_value=True)), \
patch('os.unlink', Mock()),\ patch('os.unlink', Mock()), \
patch.object(ConfigHandler, 'save_configuration_files', Mock()),\ patch.object(ConfigHandler, 'save_configuration_files', Mock()), \
patch.object(ConfigHandler, 'restore_configuration_files', Mock()),\ patch.object(ConfigHandler, 'restore_configuration_files', Mock()), \
patch.object(ConfigHandler, 'write_recovery_conf', Mock()): patch.object(ConfigHandler, 'write_recovery_conf', Mock()):
with self.assertRaises(Exception) as e: with self.assertRaises(Exception) as e:
self.b.bootstrap(config) self.b.bootstrap(config)
+9 -5
View File
@@ -1,3 +1,4 @@
import time
from mock import Mock, patch from mock import Mock, patch
from patroni.postgresql.citus import CitusHandler from patroni.postgresql.citus import CitusHandler
@@ -16,7 +17,7 @@ class TestCitus(BaseTestPostgresql):
self.cluster = get_cluster_initialized_with_leader() self.cluster = get_cluster_initialized_with_leader()
self.cluster.workers[1] = self.cluster self.cluster.workers[1] = self.cluster
@patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280])) @patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370]))
@patch('patroni.postgresql.citus.logger.exception', Mock(side_effect=SleepException)) @patch('patroni.postgresql.citus.logger.exception', Mock(side_effect=SleepException))
@patch('patroni.postgresql.citus.logger.warning') @patch('patroni.postgresql.citus.logger.warning')
@patch('patroni.postgresql.citus.PgDistNode.wait', Mock()) @patch('patroni.postgresql.citus.PgDistNode.wait', Mock())
@@ -51,7 +52,7 @@ class TestCitus(BaseTestPostgresql):
'leader': 'leader', 'timeout': 30, 'cooldown': 10}) 'leader': 'leader', 'timeout': 30, 'cooldown': 10})
def test_add_task(self): def test_add_task(self):
with patch('patroni.postgresql.citus.logger.error') as mock_logger,\ with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)): patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)):
self.c.add_task('', 1, None) self.c.add_task('', 1, None)
mock_logger.assert_called_once() mock_logger.assert_called_once()
@@ -66,11 +67,14 @@ class TestCitus(BaseTestPostgresql):
mock_logger.assert_called_once() mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:')) self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:'))
# add_task called from sync_pg_dist_node should not override already scheduled or in flight task # add_task called from sync_pg_dist_node should not override already scheduled or in flight task until deadline
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres', 30)) self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres', 30))
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres')) self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
self.c._in_flight = self.c._tasks.pop() self.c._in_flight = self.c._tasks.pop()
self.c._in_flight.deadline = self.c._in_flight.timeout + time.time()
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres')) self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
self.c._in_flight.deadline = 0
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
# If there is no transaction in progress and cached pg_dist_node matching desired state task should not be added # If there is no transaction in progress and cached pg_dist_node matching desired state task should not be added
self.c._schedule_load_pg_dist_node = False self.c._schedule_load_pg_dist_node = False
@@ -103,7 +107,7 @@ class TestCitus(BaseTestPostgresql):
self.c.process_tasks() self.c.process_tasks()
self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres') self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres')
with patch('patroni.postgresql.citus.logger.error') as mock_logger,\ with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)): patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
self.c.process_tasks() self.c.process_tasks()
mock_logger.assert_called_once() mock_logger.assert_called_once()
@@ -136,8 +140,8 @@ class TestCitus(BaseTestPostgresql):
self.assertEqual(parameters['shared_preload_libraries'], 'citus,foo,bar') self.assertEqual(parameters['shared_preload_libraries'], 'citus,foo,bar')
self.assertEqual(parameters['wal_level'], 'logical') self.assertEqual(parameters['wal_level'], 'logical')
@patch.object(CitusHandler, 'is_enabled', Mock(return_value=False))
def test_bootstrap(self): def test_bootstrap(self):
self.c._config = None
self.c.bootstrap() self.c.bootstrap()
def test_ignore_replication_slot(self): def test_ignore_replication_slot(self):
+7 -5
View File
@@ -19,10 +19,10 @@ class TestConfig(unittest.TestCase):
def test_set_dynamic_configuration(self): def test_set_dynamic_configuration(self):
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)): with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
self.assertIsNone(self.config.set_dynamic_configuration({'foo': 'bar'})) self.assertFalse(self.config.set_dynamic_configuration({'foo': 'bar'}))
self.assertTrue(self.config.set_dynamic_configuration({'synchronous_mode': True, self.assertTrue(self.config.set_dynamic_configuration({'standby_cluster': {}, 'postgresql': {
'standby_cluster': {}, 'master_start_timeout': 1})) 'parameters': {'cluster_name': 1, 'hot_standby': 1, 'wal_keep_size': 1,
self.assertEqual(self.config.get('primary_start_timeout'), 1) 'track_commit_timestamp': 1, 'wal_level': 1}}}))
def test_reload_local_configuration(self): def test_reload_local_configuration(self):
os.environ.update({ os.environ.update({
@@ -71,7 +71,8 @@ class TestConfig(unittest.TestCase):
'PATRONI_REPLICATION_USERNAME': 'replicator', 'PATRONI_REPLICATION_USERNAME': 'replicator',
'PATRONI_REPLICATION_PASSWORD': 'rep-pass', 'PATRONI_REPLICATION_PASSWORD': 'rep-pass',
'PATRONI_admin_PASSWORD': 'admin', 'PATRONI_admin_PASSWORD': 'admin',
'PATRONI_admin_OPTIONS': 'createrole,createdb' 'PATRONI_admin_OPTIONS': 'createrole,createdb',
'PATRONI_POSTGRESQL_BIN_POSTGRES': 'sergtsop'
}) })
config = Config('postgres0.yml') config = Config('postgres0.yml')
with patch.object(Config, '_load_config_file', Mock(return_value={'restapi': {}})): with patch.object(Config, '_load_config_file', Mock(return_value={'restapi': {}})):
@@ -84,6 +85,7 @@ class TestConfig(unittest.TestCase):
@patch('os.path.exists', Mock(return_value=True)) @patch('os.path.exists', Mock(return_value=True))
@patch('os.remove', Mock(side_effect=IOError)) @patch('os.remove', Mock(side_effect=IOError))
@patch('os.close', Mock(side_effect=IOError)) @patch('os.close', Mock(side_effect=IOError))
@patch('os.chmod', Mock())
@patch('shutil.move', Mock(return_value=None)) @patch('shutil.move', Mock(return_value=None))
@patch('json.dump', Mock()) @patch('json.dump', Mock())
def test_save_cache(self): def test_save_cache(self):
+23 -14
View File
@@ -4,7 +4,7 @@ import unittest
from consul import ConsulException, NotFound from consul import ConsulException, NotFound
from mock import Mock, PropertyMock, patch from mock import Mock, PropertyMock, patch
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \ from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError
from . import SleepException from . import SleepException
@@ -15,6 +15,8 @@ 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/sync':
return '1', {'ModifyIndex': 1, 'Value': b'{}'}
good_cls = ('6429', good_cls = ('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''},
@@ -26,12 +28,12 @@ def kv_get(self, key, **kwargs):
'ModifyIndex': 2621, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7', 'Value': b'postgresql1'}, 'ModifyIndex': 2621, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7', 'Value': b'postgresql1'},
{'CreateIndex': 6156, 'Flags': 0, 'Key': key + 'members/postgresql0', 'LockIndex': 1, {'CreateIndex': 6156, 'Flags': 0, 'Key': key + 'members/postgresql0', 'LockIndex': 1,
'ModifyIndex': 6156, 'Session': '782e6da4-ed02-3aef-7963-99a90ed94b53', 'ModifyIndex': 6156, 'Session': '782e6da4-ed02-3aef-7963-99a90ed94b53',
'Value': ('postgres://replicator:[email protected]:5432/postgres' + 'Value': ('postgres://replicator:[email protected]:5432/postgres'
'?application_name=http://127.0.0.1:8008/patroni').encode('utf-8')}, + '?application_name=http://127.0.0.1:8008/patroni').encode('utf-8')},
{'CreateIndex': 2630, 'Flags': 0, 'Key': key + 'members/postgresql1', 'LockIndex': 1, {'CreateIndex': 2630, 'Flags': 0, 'Key': key + 'members/postgresql1', 'LockIndex': 1,
'ModifyIndex': 2630, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7', 'ModifyIndex': 2630, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7',
'Value': ('postgres://replicator:[email protected]:5433/postgres' + 'Value': ('postgres://replicator:[email protected]:5433/postgres'
'?application_name=http://127.0.0.1:8009/patroni').encode('utf-8')}, + '?application_name=http://127.0.0.1:8009/patroni').encode('utf-8')},
{'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,
@@ -174,27 +176,30 @@ class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.Session, 'renew') @patch.object(consul.Consul.Session, 'renew')
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException)) @patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_update_leader(self, mock_renew): def test_update_leader(self, mock_renew):
leader = self.c.get_cluster().leader
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8' self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
with patch.object(consul.Consul.KV, 'delete', Mock(return_value=True)): with patch.object(consul.Consul.KV, 'delete', Mock(return_value=True)):
with patch.object(consul.Consul.KV, 'put', Mock(return_value=True)): with patch.object(consul.Consul.KV, 'put', Mock(return_value=True)):
self.assertTrue(self.c.update_leader(12345, failsafe={'foo': 'bar'})) self.assertTrue(self.c.update_leader(leader, 12345, failsafe={'foo': 'bar'}))
with patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException)): with patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException)):
self.assertFalse(self.c.update_leader(12345)) self.assertFalse(self.c.update_leader(leader, 12345))
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 100, 200, 300])): with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 100, 200, 300])):
self.assertRaises(ConsulError, self.c.update_leader, 12345) self.assertRaises(ConsulError, self.c.update_leader, leader, 12345)
with patch('time.time', Mock(side_effect=[0, 100, 200, 300])): with patch('time.time', Mock(side_effect=[0, 100, 200, 300])):
self.assertRaises(ConsulError, self.c.update_leader, 12345) self.assertRaises(ConsulError, self.c.update_leader, leader, 12345)
with patch.object(consul.Consul.KV, 'delete', Mock(side_effect=ConsulException)): with patch.object(consul.Consul.KV, 'delete', Mock(side_effect=ConsulException)):
self.assertFalse(self.c.update_leader(12347)) self.assertFalse(self.c.update_leader(leader, 12347))
mock_renew.side_effect = RetryFailedError('') mock_renew.side_effect = RetryFailedError('')
self.c._last_session_refresh = 0 self.c._last_session_refresh = 0
self.assertRaises(ConsulError, self.c.update_leader, 12346) self.assertRaises(ConsulError, self.c.update_leader, leader, 12346)
mock_renew.side_effect = ConsulException mock_renew.side_effect = ConsulException
self.assertFalse(self.c.update_leader(12347)) self.assertFalse(self.c.update_leader(leader, 12347))
@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):
self.c.delete_leader() self.c.delete_leader()
self.c._name = 'other'
self.c.delete_leader()
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True)) @patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_initialize(self): def test_initialize(self):
@@ -222,7 +227,11 @@ class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True)) @patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True)) @patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
def test_sync_state(self): def test_sync_state(self):
self.assertTrue(self.c.set_sync_state_value('{}')) self.assertEqual(self.c.set_sync_state_value('{}'), 1)
with patch('time.time', Mock(side_effect=[1, 100, 1000])):
self.assertFalse(self.c.set_sync_state_value('{}'))
with patch.object(consul.Consul.KV, 'put', Mock(return_value=False)):
self.assertFalse(self.c.set_sync_state_value('{}'))
self.assertTrue(self.c.delete_sync_state()) self.assertTrue(self.c.delete_sync_state())
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True)) @patch.object(consul.Consul.KV, 'put', Mock(return_value=True))

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