Compare commits

...
131 Commits
Author SHA1 Message Date
Alexander KukushkinandGitHub 412d508023 Bugfix: raise ConfigParseError (#2463)
instead of returning error string
2022-11-28 11:44:06 +01:00
Alexander KukushkinandGitHub 53f89faaab Release v2.1.5 (#2462)
* bump version
* update release notes
* run some behave tests on v15
* automate release process by building/pushing packages on tag creation and release publication
2022-11-28 10:45:04 +01:00
John A. LotoskiandGitHub 2ed1793bbd fix: update service on consul token rotation (#2450)
Close #2449
2022-11-18 08:10:32 +01:00
Alexander KukushkinandGitHub 1b6e23ab6a Add Polina to maintainers (#2451)
and remove some old names
2022-11-10 10:22:42 +01:00
ef2922fe37 Bump actions, install wheel (#2446)
* Bump actions/checkout and actions/setup-python versions
* Install wheel to silence some warnings

Co-authored-by: Alexander Kukushkin <[email protected]>
2022-11-01 13:55:15 +01:00
Alexander KukushkinandGitHub bda2bedf48 Make sure self.__retry_timeout is set (#2440)
The default value was None, which is the same as no timeout.
2022-10-25 14:14:16 +02:00
Alexander KukushkinandGitHub 5a21ffa3e4 Fix a little bug in check_logical_slots_readiness() (#2439)
If there is no `catalog_xmin` on physical slot on the primary the only further check that makes sense is whether the `hot_standby_feedback` is enabled.

Close https://github.com/zalando/patroni/issues/2438
2022-10-24 15:24:36 +02:00
Alexander KukushkinandGitHub 4ecaf445fa Introduce configurable timeout in TcpUtility (#2435)
the value is calculated based on the number of nodes and retry_timeout.

Close https://github.com/zalando/patroni/issues/2431
2022-10-24 10:30:28 +02:00
Alexander KukushkinandGitHub a293b77d25 Compatibility with prettytable 2.2.0+ (#2436)
`patronictl list` and `patronictl topology` didn't worked correctly starting from prettytable 2.2.0 (https://github.com/jazzband/prettytable/pull/104).
Example of wrong output:
```
+----------+-----------+---------+---------+----+-----------+
| Member   | Host      | Role    | State   | TL | Lag in MB |
+ Cluster: demo-cluster-1 (7155048679605235377) +-----------+
| node-2   | 10.2.0.21 | Leader  | running |  1 |           |
| + node-1 | 10.2.0.6  | Replica | running |  1 |         0 |
| + node-3 | 10.2.0.25 | Replica | running |  1 |         0 |
+----------+-----------+---------+---------+----+-----------+
```
2022-10-24 10:23:21 +02:00
Alexander KukushkinandGitHub 8f8e9c9b81 Inptroduce postgresql.proxy_address (#2437)
It will be written to member key in DCS as the `proxy_url` and could be used/useful for service discovery.
2022-10-24 10:23:06 +02:00
Alexander KukushkinandGitHub 580530b30f Behave tests on Windows (#2432)
Windows doesn't support `SIGTERM`, but our behave tests in majority of cases relying on Patroni graceful shutdown.
In order to emulate the behaviour we introduced the new REST API endpoint `POST /sigterm`. The endpoint works only on Windows and when `BEHAVE_DEBUG` environment variable is set.
Besides that some minor adjustments in behave tests were done. Mainly related to backslash-slash handling.

In addition to that improve test coverage on Windows by properly mocking access to filesystem and avoiding calling
 `subprocess.call()`. Specifically, symlink creation on Windows requires Admin privileges and there is no `true.exe`.
2022-10-21 12:24:24 +02:00
Alexander KukushkinandGitHub f4ae55b92a Remove 'enable_group_by_reordering' from GUC validator (#2426)
The feature was recently reverted.
2022-10-13 10:53:29 +02:00
Alexander KukushkinandGitHub 816b66311b A small fix in unit tests (#2427)
Not all external resources were properly mocked
2022-10-13 10:53:13 +02:00
Alexander KukushkinandGitHub 8a227aa743 Explicitly shut down SSL connection before socket (#2425)
This is handled by calling the unwrap() method on SSLSocket.
In addition to that simplify code that handles deferred handshakes.

Close https://github.com/zalando/patroni/issues/2424
2022-10-13 10:34:43 +02:00
Alexander KukushkinandGitHub 531063f676 Compatibility with kazoo-2.9.0 (#2428)
Now the select() method may raise `TypeError` and `IOError` exceptions if the socket is closed.
2022-10-13 09:18:06 +02:00
Ants AasmaandGitHub db9b5962ec Avoid cloning while bootstrap is running (#2419)
If cluster has a create replica method that does not require a leader it can get triggered while bootstrap is running. If that method comes up with an accessible cluster faster than the bootstrap completes it will get promoted as the leader, only to lose leader lock when bootstrap completes. To fix this we only consider leaderless create replica methods if sysid is non-empty, i.e. there is no bootstrap running.
2022-09-29 13:39:51 +02:00
Polina BunginaandGitHub 3dcdb16d2a Fix exception handling in create_config_service (#2423) 2022-09-29 09:37:01 +02:00
Jim Chanco JrandGitHub 84dc72b031 docs: Change term "Master" to "primary" or "leader" (#2417) 2022-09-29 08:48:49 +02:00
Alexander KukushkinandGitHub 7102346f87 Update release.sh (#2421)
in order to reflect the current release process
2022-09-27 09:43:38 +02:00
Alexander KukushkinandGitHub 6d8d1a2556 Make sure only sync node tries to grab the lock when switchover (#2406)
- We use `failover.leader` check as an indicator of switchover. The check on `failover.candidate` was unnecessary and incorrect.
- Add more unit-tests for switchover in sync mode

Close https://github.com/zalando/patroni/issues/2405
Co-authored-by: Polina Bungina <[email protected]>
2022-09-19 11:22:35 +02:00
Alexander KukushkinandGitHub 88db6018ac Improve liveness probe (#2395)
it will start failing if the heartbeat loop isn't running longer than `ttl` on the primary or `2*ttl` on the replica.

Close https://github.com/zalando/patroni/issues/2388
2022-09-01 11:34:42 +02:00
Denis LaxaldeandGitHub cea1fa869b Accept '*:<port>' for postgresql.listen (#2398)
We catch this special value when validating configuration and check that
it's alone in the hosts list.

Fixes #2397.
2022-08-26 07:49:27 +02:00
Alexander KukushkinandGitHub 4a854a71c0 Call pg_replication_slot_advance() from a thread (#2391)
On busy clusters with many logical replication slots the pg_replication_slot_advance () call affects the main HA loop and could result in the member key expiration.
The only way to solve it is a dedicated thread response for moving slots forward.

The thread is started only when there are logical slots to be advanced.

Will help to solve #2388
Close #2239
2022-08-24 13:43:09 +02:00
Nick HudsonandGitHub a2ef950e08 Ignore 403s when trying to create Kubernetes Service (#2390)
Close #1132
2022-08-24 13:22:53 +02:00
Robert CutajarandGitHub f92d975e7b #2021 add HEAD support - minimal (#2360) 2022-08-19 13:27:08 +02:00
Polina BunginaandGitHub 2ee09d0a66 Check if .ready file exists in _archive_ready_wals (#2387) 2022-08-18 09:07:21 +02:00
ae0ede6944 Archive possibly missing WALs before rewind (#2384)
There is currently a risk to lose some WAL segments entirely in case
archive_mode was set to 'on' before a promotion and there are some WALs
with .ready files on the former leader we are trying to rewind. It happens
because of the pg_rewind's modus operandi: it simply syncs the content of
pg_wal directory of the old leader with the new leader's one. Including
deletion of all WALs that are not present on the current leader, regardless
their archive status on the former one. Thus, in case the new leader has
already recycled such files, we just remove them entirely.

In case archive_mode was set to 'always' and the .ready WALs are acrually
present in archive, it is for end user who writes the archive_command to
avoid overwritting and to properly test it.

Co-authored-by: Alexander Kukushkin <[email protected]>
2022-08-17 09:54:30 +02:00
Alexander KukushkinandGitHub b6f057850a Apply timeout when waiting for user backends to close (#2382)
Close #2365
2022-08-17 09:23:07 +02:00
Alexander KukushkinandGitHub a0b32379e5 Handle the case when data dir storage disappeared (#2381)
The `os.listdir()` is raising the OSError exception, breaking the heart-beat loop.

Close https://github.com/zalando/patroni/issues/2380
2022-08-15 15:11:59 +02:00
Alexander KukushkinandGitHub 2d08e88c3e Don't drop replication slots in pause (#2383)
If replication slots are enabled Patroni automatically creates them for any cluster member that is supposed to stream from a given node and for any permanent slot defined in the global configuration. If the member disappears from the DCS Patroni automatically removes the replication slot for it. The same behavior was in the maintenance mode (pause).

This commit disables removal of any replication slots that don't match Patroni's expectations in pause.

Close https://github.com/zalando/patroni/issues/2314
2022-08-15 15:11:27 +02:00
Polina BunginaandGitHub ea2b7d2368 Disable the option to open an empty issue (#2377)
Make @CyberDem0n happier
2022-08-04 17:14:12 +02:00
Michael BanckandGitHub f65efecac9 Clarify standby cluster documentation. (#2369)
This adds a paragraph to the Standby Cluster section clarifying that the standby cluster is independent of the primary cluster and not visible from the primary cluster's Patroni interface.

Close #2090
2022-08-02 10:14:37 +02:00
Nikolay SamokhvalovandGitHub a8b73ef021 systemd service options: restart patroni service if it crashed (#2372)
It makes little sense to have ` for the Patroni service – if it goes down and we don't notice it (I doubt that patroni service uptime is well monitored in most cases), then we lose autofailover for Postgres, implying bigger risks of downtime.

`Restart=on-failure` makes more sense in this case.
2022-08-01 14:55:45 +02:00
Alexander KukushkinandGitHub d8d634125c Compatibility with the latest flake8 (#2373)
Require flake8 at least 3.0.0 and just call main()
2022-08-01 12:25:04 +02:00
Alexander KukushkinandGitHub ead798d9ac Speed up behave tests by always using loop_wait=2 (#2361)
run time is reduced from ~5m30s to ~5m
2022-07-18 15:23:55 +02:00
Alexander KukushkinandGitHub cd5d20fa53 Fix bug with GET /read-only-sync endpoint (#2350)
effectively it never worked
2022-07-14 08:07:45 +02:00
Alexander KukushkinandGitHub 4c5cce5efd Automatically skip some behave tests on legacy Postgres (#2358)
previously behave had to be started with `--tags=-skip` argument.
2022-07-13 12:13:36 +02:00
Alexander KukushkinandGitHub 5b1fd23776 Always return checkpoint location as integer (#2349)
before it was also returning a str in some cases
2022-06-30 10:52:28 +02:00
Denis LaxaldeandGitHub 741243695a Improvements to 'patroni --validate-config' (#2344)
* Let `patroni --validate-config` exit 1 when config is invalid
* Print configuration errors to stderr

Close #2345
2022-06-30 10:51:47 +02:00
Victor SudakovandGitHub 8d7828b079 Location of postgresql.conf on the remote master. (#2343)
Close #2337
2022-06-30 10:50:38 +02:00
sahapasciandGitHub 8d773be533 Update SETTINGS.rst (#2339)
consul.service_check_interval defaults to 5 seconds
2022-06-30 10:49:56 +02:00
Lev KozlovandGitHub b8a6387236 Bump Postgres version in Dockerfile to 14 (#2333) 2022-06-13 15:26:01 +02:00
monsterxx03andGitHub c7ee5f008d Handle expired token for etcd lease_grant (#2331) (#2332)
Close #2331
2022-06-13 14:58:11 +02:00
Michael BanckandGitHub a77fbb1912 Fix markup - the -status is part of the command (#2323) 2022-06-13 14:57:28 +02:00
Alexander KukushkinandGitHub fb06af9adb Release 2.1.4 (#2322)
- bump version
- update release notes
- implement missing unit-tests
2022-06-01 16:00:56 +02:00
Dennis4bandGitHub b42550aad4 Add /read-only-sync endpoint (#2305) (#2311)
`/read-only-sync` mirrors `/read-only`, but only returns `200` on a replica if this replica is a synchronous standby.
2022-05-30 17:09:43 +02:00
Alexander KukushkinandGitHub f67174d7cc Use replication credential in divergance check only with v10 and older (#2308)
and document in which case pg_hba.conf should allow access to "postgres" database with replication credentials.

Close #2261
2022-05-20 10:24:49 +02:00
Alexander KukushkinandGitHub 4c14b302f6 Remove [] characters from IPv6 hosts when splitting to host and port (#2309)
Close #2269
2022-05-20 10:24:25 +02:00
Alexander KukushkinandGitHub 729f1dddc8 Compatibility with PostgreSQL 15 beta1 (#2299)
* update postgresql/validator.py
* pg_rewind doesn't like if there are unix sockets in PGDATA
* pg_rewind now supports --config-file option
2022-05-19 15:36:09 +02:00
Alexander KukushkinandGitHub ad3d953410 K8s: reset watchers if PATCH fails with 409 (#2283)
High CPU load on Etcd nodes and K8s API servers created a very strange situation. A few clusters were running without a leader and the pod which is ahead of others was failing to take a leader lock because updates were failing with HTTP response code `409` (`resource_version` mismatch).

Effectively that means that TCP connections to K8s master nodes were alive (in the opposite case tcp keepalives would have resolved it), but no `UPDATE` events were arriving via these connections, resulting in the stale cache of the cluster in memory.

The only good way to prevent this situation is to intercept 409 HTTP responses and terminate existing TCP connections used for watches.

Now a few words about implementation. Unfortunately, watch threads are waiting in the read() call most of the time and there is no good way to interrupt them. But, the `socket.shutdown()` seems to do this job. We already used this trick in the Etcd3 implementation.

This approach will help to mitigate the issue of not having a leader, but at the same time replicas might still end up with the stale cluster state cached and in the worst case will not stream from the leader. Non-streaming replicas are less dangerous and could be covered by monitoring and partially mitigated by correctly configured `archive_command` and `restore_command`.
2022-05-19 15:24:20 +02:00
Alexander KukushkinandGitHub ef3401c17f Don't reset slots annotation if postgres isn't ready (#2306)
The current state of permanent logical replication slots on the primary is queried together with `pg_current_wal_lsn()` and hence they "fail" simultaneously if Postgres isn't yet ready for accepting connections and in this case we want to avoid updating the `/status` key altogether.

On K8s we don't use a dedicated object for the `/status` key, but use the same object (Endpoint or ConfigMap) as for the leader. If the `last_lsn` isn't set we avoid patching the corresponding annotation, but, the `slots` annotation was reset due to the oversight.
2022-05-19 15:06:59 +02:00
Alexander KukushkinandGitHub 496d14e6ca Better handling of failed pg_rewind attempt (#2304)
Close #2302
2022-05-19 14:52:26 +02:00
Alexander KukushkinandGitHub 6e8b2ce0a4 Don't try to run crash recovery if postgres is running (#2298)
It was a minor oversight of #2252 and didn't get to a release
2022-05-19 13:46:49 +02:00
Alexander KukushkinandGitHub 96b75fa7cb Special handling of check_recovery_conf for v12+ (#2292)
When starting as a replica it may take some time before Postgres starts accepting new connections, but meanwhile, it could happen that the leader transitioned to a different member and the `primary_conninfo` must be updated.

On pre v12 Patroni regularly checks `recovery.conf` in order to check that recovery parameters match the expectation. Starting from v12 recovery parameters were converted to GUC's and Patroni gets current values from the `pg_settings` view. The last one creates a problem when it takes more than a minute for Postgres to start accepting new connections.

Since Patroni attempts to execute at least `pg_is_in_recovery()` every HA loop, and it is raising at exception, the `check_recovery_conf()` effectively wasn't reachable until recovery is finished, but it changed when #2082 was introduced.

As a result of #2082 we got the following behavior:
1. Up to v12 (not including) everything was working as expected
2. v12 and v13 - Patroni restarting Postgres after 1m of recovery
3. v14+ - the `check_recovery_conf()` is not executed because the `replay_paused()` method raising an exception.

In order to properly handle changes of recovery parameters or leader transitioned to a different node on v12+, we will rely on the cached values of recovery parameters until Postgres becomes ready to execute queries.

Close https://github.com/zalando/patroni/issues/2289
2022-05-12 07:45:49 +02:00
Alexander KukushkinandGitHub b901e62ad0 Enhanced checks of replica logical slots safety (#2285)
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`
2. has a `catalog_xmin` that is not newer (greater) than the  `catalog_xmin` of any slot on the standby
3. the `catalog_xmin` is known to overtake `catalog_xmin` of logical slots on the primary observed during `1`

In case if `1` doesn't take place, Patroni will run an additional check whether the `hot_standby_feedback` is actually in effect and shows the warning in case it is not.
2022-05-10 12:24:47 +02:00
haslersnandGitHub 7a2d6dc3c0 Ensure that optime annotation is a string (#2291)
Fixes #2290
2022-05-10 09:20:39 +02:00
Haitao LiandGitHub aa0cd48060 k8s: Support refreshing service account tokens (#2287)
Since Kubernetes v1.21, with projected service account token feature, service account tokens expire in 1 hour. Kubernetes clients are expected to reread the token file to refresh the token.

This patch re-reads the token file very minute for in-cluster config.

Fixes #2286

Signed-off-by: Haitao Li <[email protected]>
2022-05-05 17:35:06 +02:00
Alexander KukushkinandGitHub 5f6197aaad Don't copy logical slot if there is mismatch with the config (#2274)
A couple of times we have seen in the wild that the database for the permanent logical slots was changed in the Patroni config.

It resulted in the below situation.
On the primary:
1. The slot must be dropped before creating it in a different DB.
2. Patroni fails to drop it because the slot is in use.

Replica:
1. Patroni notice that the slot exists in the wrong DB and successfully dropping it.
2. Patroni copying the existing slot from the primary by its name with Postgres restart.

And the loop repeats while the "wrong" slot exists on the primary.

Basically, replicas are continuously restarting, which badly affects availability.

In order to solve the problem, we will perform additional checks while copying replication slot files from the primary and discard them if `slot_type`, `database`, or `plugin` don't match our expectations.
2022-04-14 12:10:37 +02:00
Alexander KukushkinandGitHub aea0589404 Switch to boto3 (#2275)
Close https://github.com/zalando/patroni/issues/2237
2022-04-14 10:47:16 +02:00
zejeanmiandGitHub 40b5db4b85 Add ppc64le and fix inverted IOC_READ/WRITE vars (#2271)
Close #2265
2022-04-14 10:46:01 +02:00
Wesley MendesandGitHub e491edd1bf Fixes missing import of dateutil.parser (#2259)
Close #2258
2022-04-14 10:45:00 +02:00
James StroudandGitHub 0057f9018b Spell out DCS (#2228) 2022-03-24 13:58:10 +01:00
gremboandGitHub c4e208ec50 Allow setting TLSServerName on consul service checks (#2231)
See also https://www.consul.io/api-docs/agent/check#tlsservername
Useful in case checks are done by IP and the consul `node_name` is not an FQDN.
2022-03-24 13:57:17 +01:00
Gunnar "Nick" BluthandGitHub 7626b5fef8 Fix pg_rewind on typical Debian/Ubuntu systems (#2225)
On Debian/Ubuntu systems it is common to keep Postgres config files outside of the data directory.
It created a couple of problems for pg_rewind support in Patroni.
1. The `--config_file` argument must be supplied while figuring out the `restore_command` GUC value on Postgres v12+
2. With Postgres v13+ pg_rewind by itself can't find postgresql.conf in order to figure out `restore_command` and therefore we have to use Patroni as a fallback for fetching missing WAL's that are required for rewind.

This commit addresses both problems.
2022-03-24 13:56:16 +01:00
Alexander KukushkinandGitHub 81912c9cae Handle rewind when demoted node was shut down (#2252)
In case of DCS unavailability Patroni restarts Postgres in read-only.
It will cause pg_control to be updated with the `Database cluster state: in archive recovery` and also could set the `MinRecoveryPoint`.

When the next time Patroni is started it will assume that Postgres was running as a replica and rewind isn't required and will try to start the Postgres up. In this situation there is the chance that the start will be aborted with the FATAL error message that looks like `requested timeline 2 does not contain minimum recovery point 0/501E8B8 on timeline 1`.
On the next heart-beat Patroni will again notice that Postgres isn't running which would lead to another start-fail attempt.

This loop is endless.

In order to mitigate the problem we do the following:
1. While figuring out whether the rewind is required we consider `in archive recovery` along with `shut down in recovery`.
2. If pg_rewind is required and the cluster state is `in archive recovery` we also perform recovery in a single-user mode.

Close https://github.com/zalando/patroni/issues/2242
2022-03-24 13:51:59 +01:00
Alexander KukushkinandGitHub 333d41d9f0 Release 2.1.3 (#2219)
* Implement missing unit-tests
* Bump version
* Update release notes
2022-02-18 14:16:15 +01:00
Alexander KukushkinandGitHub aa91557a80 Fix bug in divergence timeline check (#2221)
Patroni was falsely assuming that timelines have diverged.
For pg_rewind it didn't create any problem, but if pg_rewind is not allowed and the `remove_data_directory_on_diverged_timelines` is set, it resulted in reinitializing the former leader.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ref: https://github.com/zalando/patroni/issues/2046#issuecomment-912521502
2021-09-17 14:44:54 +02:00
Alexander KukushkinandGitHub 21145d18d1 Delay the next attempt of recovery till next HA loop (#2054)
If Postgres crashed due to out of disk space (for example) and fails to start because of that Patroni is too eagerly trying to recover it and producing too many logs
2021-09-17 13:46:46 +02:00
Alexander KukushkinandGitHub 93efa91bbd Release 2.1.1 (#2039)
* Update release notes
* Bump version
* Improve unit-test coverage
2021-08-19 15:44:37 +02:00
David PavlíčekandGitHub c9f420fd4a Fix for bad string format in etcd srv_suffix resolution (#2037)
Fix for silly mistake introduced in #2029. This time tested on real ETCD and DNS.
2021-08-19 08:32:45 +02:00
Tommy LiandGitHub ed0e308b9b Support dynamically registering/deregistering as a consul service and changing tags (#1993)
Close #1988
2021-08-17 16:38:10 +02:00
Aron ParsonsandGitHub 313adb61ec Make the CA bundle configurable for in-cluster Kubernetes config (#2025)
Close https://github.com/zalando/patroni/issues/1758
2021-08-17 16:15:39 +02:00
Alexander KukushkinandGitHub db12051a5b Improve compatibility with latest minor releases (#2034)
The commit https://github.com/postgres/postgres/commit/93a0bf2390327a482ff37317f6e17547e735409e changed the behavior of `pg_settings.pending_restart`. Mostly it will not cause issues because usually people very rarely removing values from
the config, but one case is unique. If Patroni is restarted for an upgrade and it finds that Postgres is up and running, it rewrites
`postgresql.conf` and performs a reload. As a result, recovery parameters are removed from the config for Postgres v12+ and the `pending_restart` flag is falsely set. In order to partially mitigate the problem before it is fixed in Postgres we will skip recovery parameters when checking `pending_restart` flags in the `pg_settings`.

In addition to that, remove two parameters from the validator because they were reverted from Postgres v14.
2021-08-17 16:14:10 +02:00
惠雅林andGitHub c51234557d Enrich history with the new leader (#2032)
Close https://github.com/zalando/patroni/issues/2009
2021-08-13 16:05:45 +02:00
DavidPavlicekandGitHub 195b8bf049 Support for ETCD SRV name suffix (#2029)
Add support for ETCD SRV name suffix as per description in ETCD dosc:

> The -discovery-srv-name flag additionally 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. For example, if discovery-srv=example.com and -discovery-srv-name=foo are set, the following DNS SRV queries are made:
> 
> _etcd-server-ssl-foo._tcp.example.com
> _etcd-server-foo._tcp.example.com

All test passes, but not been tested on the live ETCD system yet... Please, take a look and send feedback. 

Resolves #2028
2021-08-13 15:49:01 +02:00
Alexander KukushkinandGitHub ccfe30729e Skip NULLs in the pg_stat_replication (#2016)
Close https://github.com/zalando/patroni/issues/2014
2021-08-13 15:48:15 +02:00
Alexander KukushkinandGitHub c81391e314 Don't resolve cluster members when use_proxies is set (#2007)
Close https://github.com/zalando/patroni/issues/2006
2021-07-21 08:36:21 +02:00
Michael BanckandGitHub 415180048a Add selector to StatefulSet definition (#2011)
This adds the `selector` to the Patroni Kubernetes StatefulSet spec

Without it, one gets errors like
```
error: error validating "patroni_k8s.yaml": error validating data: ValidationError(StatefulSet.spec): missing required field "selector" in io.k8s.api.apps.v1.StatefulSetSpec; if you choose to ignore these errors, turn validation off with --validate=false
```
(as mentioned in #1867)
2021-07-21 08:35:51 +02:00
Alexander KukushkinandGitHub 9288ce066b Reload REST API certificate only on SIGHUP (#2004)
And make sure that cert number is cached when the RestApiServer object is created.

Close https://github.com/zalando/patroni/issues/2003
2021-07-21 08:33:08 +02:00
Julien RiouandGitHub cb80f7ee06 docs: fix typo in 2.1.0 release note (#1999) 2021-07-09 13:21:53 +02:00
96 changed files with 2677 additions and 1161 deletions
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+11 -3
View File
@@ -18,7 +18,10 @@ def install_requirements(what):
finally:
sys.path = old_path
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
requirements += ['psycopg2-binary', 'coverage']
requirements += ['coverage']
# try to split tests between psycopg2 and psycopg3
requirements += ['psycopg[binary]'] if sys.version_info >= (3, 6, 0) and\
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
for r in read('requirements.txt').split('\n'):
r = r.strip()
if r != '':
@@ -27,6 +30,7 @@ def install_requirements(what):
requirements.append(r)
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'wheel'])
r = subprocess.call([sys.executable, '-m', 'pip', 'install'] + requirements)
s = subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'setuptools'])
return s | r
@@ -42,10 +46,14 @@ def install_packages(what):
packages['exhibitor'] = packages['zookeeper']
packages = packages.get(what, [])
ver = versions.get(what)
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
subprocess.call(['sudo', 'apt-get', 'install', '-y', 'wget', 'ca-certificates', 'gnupg', 'expect-dev'])
subprocess.call(['sudo', 'sh', '-c', "wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc"
" | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg"])
subprocess.call(['sudo', 'sed', '-i', 's/pgdg main.*$/pgdg main {0}/'.format(ver),
'/etc/apt/sources.list.d/pgdg.list'])
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev', 'wget'] + packages)
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver] + packages)
def get_file(url, name):
@@ -107,7 +115,7 @@ def install_etcd():
def install_postgres():
version = os.environ.get('PGVERSION', '12.1-1')
version = os.environ.get('PGVERSION', '14.1-1')
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
name = 'postgresql-{0}-{1}-binaries.zip'.format(version, platform)
get_file('http://get.enterprisedb.com/postgresql/' + name, name)
+1 -1
View File
@@ -1 +1 @@
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '14'}
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
+41
View File
@@ -0,0 +1,41 @@
name: Publish Patroni distributions to PyPI and TestPyPI
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
release:
types:
- published
jobs:
build-n-publish:
name: Build and publish Patroni distributions to PyPI and TestPyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Build a binary wheel and a source tarball
run: python setup.py sdist bdist_wheel
- name: Publish distribution to Test PyPI
if: github.event_name == 'push'
uses: pypa/[email protected]
with:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/
- name: Publish distribution to PyPI
if: github.event_name == 'release'
uses: pypa/[email protected]
with:
password: ${{ secrets.PYPI_API_TOKEN }}
+3 -4
View File
@@ -27,17 +27,16 @@ def main():
version = versions.get(what)
path = '/usr/lib/postgresql/{0}/bin:.'.format(version)
unbuffer = ['timeout', '600', 'unbuffer']
args = ['--tags=-skip'] if what == 'etcd' else []
unbuffer = ['timeout', '900', 'unbuffer']
else:
path = os.path.abspath(os.path.join('pgsql', 'bin'))
if sys.platform == 'darwin':
path += ':.'
args = unbuffer = []
unbuffer = []
env['PATH'] = path + os.pathsep + env['PATH']
env['DCS'] = what
ret = subprocess.call(unbuffer + [sys.executable, '-m', 'behave'] + args, env=env)
ret = subprocess.call(unbuffer + [sys.executable, '-m', 'behave'], env=env)
if ret != 0:
if subprocess.call('grep . features/output/*_failed/*postgres?.*', shell=True) != 0:
+41 -67
View File
@@ -17,9 +17,9 @@ jobs:
os: [ubuntu, windows, macos]
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
- name: Set up Python 2.7
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: 2.7
if: matrix.os != 'windows'
@@ -30,17 +30,8 @@ jobs:
run: python .github/workflows/run_tests.py
if: matrix.os != 'windows'
- name: Set up Python 3.5
uses: actions/setup-python@v2
with:
python-version: 3.5
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Set up Python 3.6
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: 3.6
- name: Install dependencies
@@ -49,7 +40,7 @@ jobs:
run: python .github/workflows/run_tests.py
- name: Set up Python 3.7
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: 3.7
- name: Install dependencies
@@ -58,7 +49,7 @@ jobs:
run: python .github/workflows/run_tests.py
- name: Set up Python 3.8
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: 3.8
- name: Install dependencies
@@ -67,7 +58,7 @@ jobs:
run: python .github/workflows/run_tests.py
- name: Set up Python 3.9
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
@@ -75,6 +66,15 @@ jobs:
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Combine coverage
run: python .github/workflows/run_tests.py combine
@@ -88,52 +88,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.github_token }}
run: python -m coveralls --service=github
- name: Run codacy-coverage-reporter
uses: codacy/codacy-coverage-reporter-action@master
env:
SECRETS_AVAILABLE: ${{ secrets.CODACY_PROJECT_TOKEN != '' }}
with:
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
coverage-reports: coverage.xml
if: ${{ matrix.os == 'ubuntu' && env.SECRETS_AVAILABLE == 'true' }}
behave:
runs-on: ${{ matrix.os }}-latest
env:
DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.3.13
strategy:
fail-fast: false
matrix:
os: [ubuntu]
python-version: [2.7, 3.5, 3.8]
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
steps:
- uses: actions/checkout@v1
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Add postgresql apt repo
run: sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run behave tests
run: python .github/workflows/run_tests.py
- uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install coveralls
run: python -m pip install coveralls
- name: Upload Coverage
env:
COVERALLS_FLAG_NAME: behave-${{ matrix.os }}-${{ matrix.dcs }}-${{ matrix.python-version }}
COVERALLS_PARALLEL: 'true'
GITHUB_TOKEN: ${{ secrets.github_token }}
run: python -m coveralls --service=github
behavem:
runs-on: ${{ matrix.os }}-latest
env:
DCS: ${{ matrix.dcs }}
@@ -142,20 +97,39 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos] #, windows]
python-version: [3.7]
dcs: [etcd, etcd3, raft]
os: [ubuntu]
python-version: [2.7, 3.6, 3.9]
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
exclude:
- dcs: kubernetes
python-version: 2.7
include:
- os: macos
python-version: 3.7
dcs: raft
- os: macos
python-version: 3.8
dcs: etcd
- os: macos
python-version: '3.10'
dcs: etcd3
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Add postgresql apt repo
run: sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
if: matrix.os == 'ubuntu'
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run behave tests
run: python .github/workflows/run_tests.py
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install coveralls
run: python -m pip install coveralls
- name: Upload Coverage
@@ -167,10 +141,10 @@ jobs:
coveralls-finish:
name: Finalize coveralls.io
needs: [unit, behave, behavem]
needs: [unit, behave]
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v2
- uses: actions/setup-python@v4
- run: python -m pip install coveralls
- run: python -m coveralls --service=github --finish
env:
+2
View File
@@ -0,0 +1,2 @@
# global owners
* @CyberDem0n @hughcapet
+2 -2
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
## It has all the necessary components to play/debug with a single node appliance, running etcd
ARG PG_MAJOR=10
ARG PG_MAJOR=15
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -117,7 +117,7 @@ RUN if [ "$COMPRESS" = "true" ]; then \
FROM scratch
COPY --from=builder / /
LABEL maintainer="Alexander Kukushkin <alexander.kukushkin@zalando.de>"
LABEL maintainer="Alexander Kukushkin <akukushkin@microsoft.com>"
ARG PG_MAJOR
ARG COMPRESS
+2 -3
View File
@@ -1,3 +1,2 @@
Alexander Kukushkin <alexander.kukushkin@zalando.de>
Feike Steenbergen <feike.steenbergen@zalando.de>
Oleksii Kliukin <[email protected]>
Alexander Kukushkin <akukushkin@microsoft.com>
Polina Bungina <polina.bungina@zalando.de>
+8 -2
View File
@@ -12,7 +12,7 @@ Patroni is a template for you to create your own customized, high-availability s
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
Currently supported PostgreSQL versions: 9.3 to 13.
Currently supported PostgreSQL versions: 9.3 to 15.
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
@@ -61,7 +61,7 @@ To install requirements on a Mac, run the following:
brew install postgresql etcd haproxy libyaml python
**Psycopg2**
**Psycopg**
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
@@ -88,6 +88,12 @@ There are a few options available:
pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
::
pip install psycopg[binary]
**General installation for pip**
Patroni can be installed with pip:
+10 -2
View File
@@ -49,6 +49,7 @@ Consul
- **PATRONI\_CONSUL\_CHECKS**: (optional) list of Consul health checks used for the session. By default an empty list is used.
- **PATRONI\_CONSUL\_REGISTER\_SERVICE**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_INTERVAL**: (optional) how often to perform health check against registered url
- **PATRONI\_CONSUL\_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>`__.
Etcd
----
@@ -59,7 +60,8 @@ Etcd
- **PATRONI\_ETCD\_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 but stick to a fixed list of **hosts**.
- **PATRONI\_ETCD\_PROTOCOL**: http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **PATRONI\_ETCD\_HOST**: the host:port for the etcd endpoint.
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
- **PATRONI\_ETCD\_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.
- **PATRONI\_ETCD\_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 **PATRONI\_ETCD\_SRV**. For example, if ``PATRONI_ETCD_SRV_SUFFIX=foo`` and ``PATRONI_ETCD_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).
- **PATRONI\_ETCD\_USERNAME**: username for etcd authentication.
- **PATRONI\_ETCD\_PASSWORD**: password for etcd authentication.
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If present it will enable validation.
@@ -83,6 +85,7 @@ ZooKeeper
- **PATRONI\_ZOOKEEPER\_KEY**: (optional) File with the client key.
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -105,6 +108,7 @@ Kubernetes
- **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\_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\_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.
Raft
----
@@ -119,11 +123,12 @@ PostgreSQL
----------
- **PATRONI\_POSTGRESQL\_LISTEN**: IP address + port that Postgres listens to. 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.
- **PATRONI\_POSTGRESQL\_CONNECT\_ADDRESS**: IP address + port through which Postgres is accessible from other nodes and applications.
- **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\_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\_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 master 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\_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\_REPLICATION\_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.
@@ -131,6 +136,7 @@ PostgreSQL
- **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REPLICATION\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REPLICATION\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_REPLICATION\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
@@ -141,6 +147,7 @@ PostgreSQL
- **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_SUPERUSER\_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\_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.
@@ -151,6 +158,7 @@ PostgreSQL
- **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REWIND\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REWIND\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_REWIND\_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.
+9 -3
View File
@@ -35,7 +35,7 @@ To install requirements on a Mac, run the following:
.. _psycopg2_install_options:
**Psycopg2**
**Psycopg**
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
@@ -62,6 +62,12 @@ There are a few options available:
pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
::
pip install psycopg[binary]>=3.0.0
**General installation for pip**
Patroni can be installed with pip:
@@ -73,7 +79,7 @@ Patroni can be installed with pip:
where dependencies can be either empty, or consist of one or more of the following:
etcd or etcd3
`python-etcd` module in order to use Etcd as DCS
`python-etcd` module in order to use Etcd as Distributed Configuration Store (DCS)
consul
`python-consul` module in order to use Consul as DCS
zookeeper
@@ -103,7 +109,7 @@ Planning the Number of PostgreSQL Nodes
---------------------------------------
Patroni/PostgreSQL nodes are decoupled from DCS nodes (except when Patroni implements RAFT on its own) and therefore
there is no requirement on the minimal number of nodes. Running a cluster consisting of one master and one standby is
there is no requirement on the minimal number of nodes. Running a cluster consisting of one primary and one standby is
perfectly fine. You can add more standby nodes later.
Running and Configuring
+23 -14
View File
@@ -17,21 +17,21 @@ Dynamic configuration is stored in the DCS (Distributed Configuration Store) and
- **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.
- **master\_start\_timeout**: the amount of time a master 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 master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **master\_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 + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **master\_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 master_stop_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, master_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 master. 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.
- **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 master
- **port**: a port of remote master
- **primary\_slot\_name**: which slot on the remote master 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 master, can be different from the list defined in :ref:`postgresql_settings`
- **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings`
- **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+.
@@ -131,7 +131,8 @@ Most of the parameters are optional, but you have to specify one of the **host**
- **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, 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``/``replica``/``standby-leader``). By default an empty list is used.
- **service\_check\_interval**: (optional) how often to perform health check against registered url.
- **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:
@@ -156,7 +157,8 @@ Most of the parameters are optional, but you have to specify one of the **host**
- **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.
- **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.
@@ -181,6 +183,7 @@ ZooKeeper
- **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.
@@ -204,6 +207,7 @@ Kubernetes
- **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.
.. _raft_settings:
@@ -220,7 +224,7 @@ Raft
- 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
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?
@@ -254,10 +258,11 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **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 master via streaming 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.
@@ -265,6 +270,7 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **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**:
@@ -276,6 +282,7 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **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.)
@@ -285,6 +292,7 @@ PostgreSQL
- **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.
@@ -307,14 +315,14 @@ PostgreSQL
- **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 master can not start streaming from the new master. This option is useful when ``pg_rewind`` can not be used. 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.
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 master 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.
- **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).
@@ -323,7 +331,7 @@ REST API
- **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.
- **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.
@@ -361,6 +369,7 @@ CTL
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **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
--------
+4 -1
View File
@@ -194,4 +194,7 @@ intersphinx_mapping = {'https://docs.python.org/': None}
# A possibility to have an own stylesheet, to add new rules or override existing ones
# For the latter case, the CSS specificity of the rules should be higher than the default ones
def setup(app):
app.add_stylesheet("custom.css")
if hasattr(app, 'add_css_file'):
app.add_css_file('custom.css')
else:
app.add_stylesheet('custom.css')
+3 -3
View File
@@ -22,7 +22,7 @@ Patroni configuration is stored in the DCS (Distributed Configuration Store). Th
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.
Some of the PostgreSQL parameters must hold the same values on the master 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:
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
@@ -32,7 +32,7 @@ Some of the PostgreSQL parameters must hold the same values on the master and th
- wal_log_hints: on
- track_commit_timestamp: off
For the parameters below, PostgreSQL does not require equal values among the master and all the replicas. However, considering the possibility of a replica to become the master at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
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
- max_wal_senders: 5
- max_replication_slots: 5
@@ -86,4 +86,4 @@ Also, the following Patroni configuration options can be changed only dynamicall
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 master is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
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.
+2 -2
View File
@@ -29,11 +29,11 @@ Major Upgrade of PostgreSQL Version
The only possible way to do a major upgrade currently is:
1. Stop Patroni
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the master node
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
3. 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.
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.
6. Start Patroni on the master node.
6. Start Patroni on the primary node.
7. 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.
+1 -1
View File
@@ -10,7 +10,7 @@ Patroni is a template for you to create your own customized, high-availability s
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
Currently supported PostgreSQL versions: 9.3 to 13.
Currently supported PostgreSQL versions: 9.3 to 15.
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation.
+1 -1
View File
@@ -23,7 +23,7 @@ Use ConfigMaps
In this mode, Patroni will create ConfigMaps instead of Endpoints and store keys inside meta-data of those ConfigMaps.
Changing the leader takes at least two updates, one to the leader ConfigMap and another to the respective Endpoint.
There are two ways to direct the traffic to the Postgres master:
There are two ways to direct the traffic to the Postgres leader:
- use the `callback script <https://github.com/zalando/patroni/blob/master/kubernetes/callback.py>`_ provided by Patroni
- configure the Kubernetes Postgres service to use the label selector with the `role_label` (configured in patroni configuration).
+7 -5
View File
@@ -6,7 +6,7 @@ Pause/Resume mode for the cluster
The goal
--------
Under certain circumstances Patroni needs to temporary step down from managing the cluster, while still retaining the cluster state in DCS. Possible use cases are uncommon activities on the cluster, such as major version upgrades or corruption recovery. During those activities nodes are often started and stopped for the reason unknown to Patroni, some nodes can be even temporary promoted, violating the assumption of running only one master. Therefore, Patroni needs to be able to "detach" from the running cluster, implementing an equivalent of the maintenance mode in Pacemaker.
Under certain circumstances Patroni needs to temporarily step down from managing the cluster, while still retaining the cluster state in DCS. Possible use cases are uncommon activities on the cluster, such as major version upgrades or corruption recovery. During those activities nodes are often started and stopped for reasons unknown to Patroni, some nodes can be even temporarily promoted, violating the assumption of running only one primary. Therefore, Patroni needs to be able to "detach" from the running cluster, implementing an equivalent of the maintenance mode in Pacemaker.
@@ -17,16 +17,18 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
- For each node, the member key in DCS is updated with the current information about the cluster. This causes Patroni to run read-only queries on a member node if the member is running.
- For the Postgres master with the leader lock Patroni updates the lock. If the node with the leader lock stops being the master (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
- For the Postgres primary with the leader lock Patroni updates the lock. If the node with the leader lock stops being the primary (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running master node.
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running primary node.
- If 'parallel' masters are detected by Patroni, it emits a warning, but does not demote the masters without the leader lock.
- If 'parallel' primaries are detected by Patroni, it emits a warning, but does not demote the primary without the leader lock.
- If there is no leader lock in the cluster, the running master acquires the lock. If there is more than one master node, then the first master to acquire the lock wins. If there are no masters altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old master has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
- If there is no leader lock in the cluster, the running primary acquires the lock. If there is more than one primary node, then the first primary to acquire the lock wins. If there are no primary altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old primary has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
- When Postgres is stopped, Patroni does not try to start it. When Patroni is stopped, it does not try to stop the Postgres instance it is managing.
- Patroni will not try to remove replication slots that don't represent the other cluster member or are not listed in the configuration of the permanent slots.
User guide
----------
+378 -2
View File
@@ -3,6 +3,382 @@
Release notes
=============
Version 2.1.5
-------------
This version enhances compatibility with PostgreSQL 15 and declares Etcd v3 support as production ready. The Patroni on Raft remains in Beta.
**New features**
- Improve ``patroni --validate-config`` (Denis Laxalde)
Exit with code 1 if config is invalid and print errors to stderr.
- Don't drop replication slots in pause (Alexander Kukushkin)
Patroni is automatically creating/removing physical replication slots when members are joining/leaving the cluster. In pause slots will no longer be removed.
- Support the ``HEAD`` request method for monitoring endpoints (Robert Cutajar)
If used instead of ``GET`` Patroni will return only the HTTP Status Code.
- Support behave tests on Windows (Alexander)
Emulate graceful Patroni shutdown (``SIGTERM``) on Windows by introduce the new REST API endpoint ``POST /sigterm``.
- Introduce ``postgresql.proxy_address`` (Alexander)
It will be written to the member key in DCS as the ``proxy_url`` and could be used/useful for service discovery.
**Stability improvements**
- Call ``pg_replication_slot_advance()`` from a thread (Alexander)
On busy clusters with many logical replication slots the ``pg_replication_slot_advance()`` call was affecting the main HA loop and could result in the member key expiration.
- Archive possibly missing WALs before calling ``pg_rewind`` on the old primary (Polina Bungina)
If the primary crashed and was down during considerable time, some WAL files could be missing from archive and from the new primary. There is a chance that ``pg_rewind`` could remove these WAL files from the old primary making it impossible to start it as a standby. By archiving ``ready`` WAL files we not only mitigate this problem but in general improving continues archiving experience.
- Ignore ``403`` errors when trying to create Kubernetes Service (Nick Hudson, Polina)
Patroni was spamming logs by unsuccessful attempts to create the service, which in fact could already exist.
- Improve liveness probe (Alexander)
The liveness problem will start failing if the heartbeat loop is running longer than `ttl` on the primary or `2*ttl` on the replica. That will allow us to use it as an alternative for :ref:`watchdog <watchdog>` on Kubernetes.
- Make sure only sync node tries to grab the lock when switchover (Alexander, Polina)
Previously there was a slim chance that up-to-date async member could become the leader if the manual switchover was performed without specifying the target.
- Avoid cloning while bootstrap is running (Ants Aasma)
Do not allow a create replica method that does not require a leader to be triggered while the cluster bootstrap is running.
- Compatibility with kazoo-2.9.0 (Alexander)
Depending on python version the ``SequentialThreadingHandler.select()`` method may raise ``TypeError`` and ``IOError`` exceptions if ``select()`` is called on the closed socket.
- Explicitly shut down SSL connection before socket shutdown (Alexander)
Not doing it resulted in ``unexpected eof while reading`` errors with OpenSSL 3.0.
- Compatibility with `prettytable>=2.2.0` (Alexander)
Due to the internal API changes the cluster name header was shown on the incorrect line.
**Bugfixes**
- Handle expired token for Etcd lease_grant (monsterxx03)
In case of error get the new token and retry request.
- Fix bug in the ``GET /read-only-sync`` endpoint (Alexander)
It was introduced in previous release and effectively never worked.
- Handle the case when data dir storage disappeared (Alexander)
Patroni is periodically checking that the PGDATA is there and not empty, but in case of issues with storage the ``os.listdir()`` is raising the ``OSError`` exception, breaking the heart-beat loop.
- Apply ``master_stop_timeout`` when waiting for user backends to close (Alexander)
Something that looks like user backend could be in fact a background worker (e.g., Citus Maintenance Daemon) that is failing to stop.
- Accept ``*:<port>`` for ``postgresql.listen`` (Denis)
The ``patroni --validate-config`` was complaining about it being invalid.
- Timeouts fixes in Raft (Alexander)
When Patroni or patronictl are starting they try to get Raft cluster topology from known members. These calls were made without proper timeouts.
- Forcefully update consul service if token was changed (John A. Lotoski)
Not doing so results in errors "rpc error making call: rpc error making call: ACL not found".
Version 2.1.4
-------------
**New features**
- Improve ``pg_rewind`` behavior on typical Debian/Ubuntu systems (Gunnar "Nick" Bluth)
On Postgres setups that keep `postgresql.conf` outside of the data directory (e.g. Ubuntu/Debian packages), ``pg_rewind --restore-target-wal`` fails to figure out the value of the ``restore_command``.
- Allow setting ``TLSServerName`` on Consul service checks (Michael Gmelin)
Useful when checks are performed by IP and the Consul ``node_name`` is not a FQDN.
- Added ``ppc64le`` support in watchdog (Jean-Michel Scheiwiler)
And fixed watchdog support on some non-x86 platforms.
- Switched aws.py callback from ``boto`` to ``boto3`` (Alexander Kukushkin)
``boto`` 2.x is abandoned since 2018 and fails with python 3.9.
- Periodically refresh service account token on K8s (Haitao Li)
Since Kubernetes v1.21 service account tokens expire in 1 hour.
- Added ``/read-only-sync`` monitoring endpoint (Dennis4b)
It is similar to the ``/read-only`` but includes only synchronous replicas.
**Stability improvements**
- Don't copy the logical replication slot to a replica if there is a configuration mismatch in the logical decoding setup with the primary (Alexander)
A replica won't copy a logical replication slot from the primary anymore if the slot doesn't match the ``plugin`` or ``database`` configuration options. Previously, the check for whether the slot matches those configuration options was not performed until after the replica copied the slot and started with it, resulting in unnecessary and repeated restarts.
- Special handling of recovery configuration parameters for PostgreSQL v12+ (Alexander)
While starting as replica Patroni should be able to update ``postgresql.conf`` and restart/reload if the leader address has changed by caching current parameters values instead of querying them from ``pg_settings``.
- Better handling of IPv6 addresses in the ``postgresql.listen`` parameters (Alexander)
Since the ``listen`` parameter has a port, people try to put IPv6 addresses into square brackets, which were not correctly stripped when there is more than one IP in the list.
- Use ``replication`` credentials when performing divergence check only on PostgreSQL v10 and older (Alexander)
If ``rewind`` is enabled, Patroni will again use either ``superuser`` or ``rewind`` credentials on newer Postgres versions.
**Bugfixes**
- Fixed missing import of ``dateutil.parser`` (Wesley Mendes)
Tests weren't failing only because it was also imported from other modules.
- Ensure that ``optime`` annotation is a string (Sebastian Hasler)
In certain cases Patroni was trying to pass it as numeric.
- Better handling of failed ``pg_rewind`` attempt (Alexander)
If the primary becomes unavailable during ``pg_rewind``, ``$PGDATA`` will be left in a broken state. Following that, Patroni will remove the data directory even if this is not allowed by the configuration.
- Don't remove ``slots`` annotations from the leader ``ConfigMap``/``Endpoint`` when PostgreSQL isn't ready (Alexander)
If ``slots`` value isn't passed the annotation will keep the current value.
- Handle concurrency problem with K8s API watchers (Alexander)
Under certain (unknown) conditions watchers might become stale; as a result, ``attempt_to_acquire_leader()`` method could fail due to the HTTP status code 409. In that case we reset watchers connections and restart from scratch.
Version 2.1.3
-------------
**New features**
- Added support for encrypted TLS keys for ``patronictl`` (Alexander Kukushkin)
It could be configured via ``ctl.keyfile_password`` or the ``PATRONI_CTL_KEYFILE_PASSWORD`` environment variable.
- Added more metrics to the /metrics endpoint (Alexandre Pereira)
Specifically, ``patroni_pending_restart`` and ``patroni_is_paused``.
- Make it possible to specify multiple hosts in the standby cluster configuration (Michael Banck)
If the standby cluster is replicating from the Patroni cluster it might be nice to rely on client-side failover which is available in ``libpq`` since PostgreSQL v10. That is, the ``primary_conninfo`` on the standby leader and ``pg_rewind`` setting ``target_session_attrs=read-write`` in the connection string. The ``pgpass`` file will be generated with multiple lines (one line per host), and instead of calling ``CHECKPOINT`` on the primary cluster nodes the standby cluster will wait for ``pg_control`` to be updated.
**Stability improvements**
- Compatibility with legacy ``psycopg2`` (Alexander)
For example, the ``psycopg2`` installed from Ubuntu 18.04 packages doesn't have the ``UndefinedFile`` exception yet.
- Restart ``etcd3`` watcher if all Etcd nodes don't respond (Alexander)
If the watcher is alive the ``get_cluster()`` method continues returning stale information even if all Etcd nodes are failing.
- Don't remove the leader lock in the standby cluster while paused (Alexander)
Previously the lock was maintained only by the node that was running as a primary and not a standby leader.
**Bugfixes**
- Fixed bug in the standby-leader bootstrap (Alexander)
Patroni was considering bootstrap as failed if Postgres didn't start accepting connections after 60 seconds. The bug was introduced in the 2.1.2 release.
- Fixed bug with failover to a cascading standby (Alexander)
When figuring out which slots should be created on cascading standby we forgot to take into account that the leader might be absent.
- Fixed small issues in Postgres config validator (Alexander)
Integer parameters introduced in PostgreSQL v14 were failing to validate because min and max values were quoted in the validator.py
- Use replication credentials when checking leader status (Alexander)
It could be that the ``remove_data_directory_on_diverged_timelines`` is set, but there is no ``rewind_credentials`` defined and superuser access between nodes is not allowed.
- Fixed "port in use" error on REST API certificate replacement (Ants Aasma)
When switching certificates there was a race condition with a concurrent API request. If there is one active during the replacement period then the replacement will error out with a port in use error and Patroni gets stuck in a state without an active API server.
- Fixed a bug in cluster bootstrap if passwords contain ``%`` characters (Bastien Wirtz)
The bootstrap method executes the ``DO`` block, with all parameters properly quoted, but the ``cursor.execute()`` method didn't like an empty list with parameters passed.
- Fixed the "AttributeError: no attribute 'leader'" exception (Hrvoje Milković)
It could happen if the synchronous mode is enabled and the DCS content was wiped out.
- Fix bug in divergence timeline check (Alexander)
Patroni was falsely assuming that timelines have diverged. For pg_rewind it didn't create any problem, but if pg_rewind is not allowed and the ``remove_data_directory_on_diverged_timelines`` is set, it resulted in reinitializing the former leader.
Version 2.1.2
-------------
**New features**
- Compatibility with ``psycopg>=3.0`` (Alexander Kukushkin)
By default ``psycopg2`` is preferred. `psycopg>=3.0` will be used only if ``psycopg2`` is not available or its version is too old.
- Add ``dcs_last_seen`` field to the REST API (Michael Banck)
This field notes the last time (as unix epoch) a cluster member has successfully communicated with the DCS. This is useful to identify and/or analyze network partitions.
- Release the leader lock when ``pg_controldata`` reports "shut down" (Alexander)
To solve the problem of slow switchover/shutdown in case ``archive_command`` is slow/failing, Patroni will remove the leader key immediately after ``pg_controldata`` started reporting PGDATA as ``shut down`` cleanly and it verified that there is at least one replica that received all changes. If there are no replicas that fulfill this condition the leader key is not removed and the old behavior is retained, i.e. Patroni will keep updating the lock.
- Add ``sslcrldir`` connection parameter support (Kostiantyn Nemchenko)
The new connection parameter was introduced in the PostgreSQL v14.
- Allow setting ACLs for ZNodes in Zookeeper (Alwyn Davis)
Introduce a new configuration option ``zookeeper.set_acls`` so that Kazoo will apply a default ACL for each ZNode that it creates.
**Stability improvements**
- Delay the next attempt of recovery till next HA loop (Alexander)
If Postgres crashed due to out of disk space (for example) and fails to start because of that Patroni is too eagerly trying to recover it flooding logs.
- Add log before demoting, which can take some time (Michael)
It can take some time for the demote to finish and it might not be obvious from looking at the logs what exactly is going on.
- Improve "I am" status messages (Michael)
``no action. I am a secondary ({0})`` vs ``no action. I am ({0}), a secondary``
- Cast to int ``wal_keep_segments`` when converting to ``wal_keep_size`` (Jorge Solórzano)
It is possible to specify ``wal_keep_segments`` as a string in the global :ref:`dynamic configuration <dynamic_configuration>` and due to Python being a dynamically typed language the string was simply multiplied. Example: ``wal_keep_segments: "100"`` was converted to ``100100100100100100100100100100100100100100100100MB``.
- Allow switchover only to sync nodes when synchronous replication is enabled (Alexander)
In addition to that do the leader race only against known synchronous nodes.
- Use cached role as a fallback when Postgres is slow (Alexander)
In some extreme cases Postgres could be so slow that the normal monitoring query does not finish in a few seconds. The ``statement_timeout`` exception not being properly handled could lead to the situation where Postgres was not demoted on time when the leader key expired or the update failed. In case of such exception Patroni will use the cached ``role`` to determine whether Postgres is running as a primary.
- Avoid unnecessary updates of the member ZNode (Alexander)
If no values have changed in the members data, the update should not happen.
- Optimize checkpoint after promote (Alexander)
Avoid doing ``CHECKPOINT`` if the latest timeline is already stored in ``pg_control``. It helps to avoid unnecessary ``CHECKPOINT`` right after initializing the new cluster with ``initdb``.
- Prefer members without ``nofailover`` when picking sync nodes (Alexander)
Previously sync nodes were selected only based on the replication lag, hence the node with ``nofailover`` tag had the same chances to become synchronous as any other node. That behavior was confusing and dangerous at the same time because in case of a failed primary the failover could not happen automatically.
- Remove duplicate hosts from the etcd machine cache (Michael)
Advertised client URLs in the etcd cluster could be misconfigured. Removing duplicates in Patroni in this case is a low-hanging fruit.
**Bugfixes**
- Skip temporary replication slots while doing slot management (Alexander)
Starting from v10 ``pg_basebackup`` creates a temporary replication slot for WAL streaming and Patroni was trying to drop it because the slot name looks unknown. In order to fix it, we skip all temporary slots when querying ``pg_stat_replication_slots`` view.
- Ensure ``pg_replication_slot_advance()`` doesn't timeout (Alexander)
Patroni was using the default ``statement_timeout`` in this case and once the call failed there are very high chances that it will never recover, resulting in increased size of ``pg_wal`` and ``pg_catalog`` bloat.
- The ``/status`` wasn't updated on demote (Alexander)
After demoting PostgreSQL the old leader updates the last LSN in DCS. Starting from ``2.1.0`` the new ``/status`` key was introduced, but the optime was still written to the ``/optime/leader``.
- Handle DCS exceptions when demoting (Alexander)
While demoting the master due to failure to update the leader lock it could happen that DCS goes completely down and the ``get_cluster()`` call raises an exception. Not being handled properly it results in Postgres remaining stopped until DCS recovers.
- The ``use_unix_socket_repl`` didn't work is some cases (Alexander)
Specifically, if ``postgresql.unix_socket_directories`` is not set. In this case Patroni is supposed to use the default value from ``libpq``.
- Fix a few issues with Patroni REST API (Alexander)
The ``clusters_unlocked`` sometimes could be not defined, what resulted in exceptions in the ``GET /metrics`` endpoint. In addition to that the error handling method was assuming that the ``connect_address`` tuple always has two elements, while in fact there could be more in case of IPv6.
- Wait for newly promoted node to finish recovery before deciding to rewind (Alexander)
It could take some time before the actual promote happens and the new timeline is created. Without waiting replicas could come to the conclusion that rewind isn't required.
- Handle missing timelines in a history file when deciding to rewind (Alexander)
If the current replica timeline is missing in the history file on the primary the replica was falsely assuming that rewind isn't required.
Version 2.1.1
-------------
**New features**
- Support for ETCD SRV name suffix (David Pavlicek)
Etcd allows to differentiate between multiple Etcd clusters under the same domain and from now on Patroni also supports it.
- Enrich history with the new leader (huiyalin525)
It adds the new column to the ``patronictl history`` output.
- Make the CA bundle configurable for in-cluster Kubernetes config (Aron Parsons)
By default Patroni is using ``/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`` and this new feature allows specifying the custom ``kubernetes.cacert``.
- Support dynamically registering/deregistering as a Consul service and changing tags (Tommy Li)
Previously it required Patroni restart.
**Bugfixes**
- Avoid unnecessary reload of REST API (Alexander Kukushkin)
The previous release added a feature of reloading REST API certificates if changed on disk. Unfortunately, the reload was happening unconditionally right after the start.
- Don't resolve cluster members when ``etcd.use_proxies`` is set (Alexander)
When starting up Patroni checks the healthiness of Etcd cluster by querying the list of members. In addition to that, it also tried to resolve their hostnames, which is not necessary when working with Etcd via proxy and was causing unnecessary warnings.
- Skip rows with NULL values in the ``pg_stat_replication`` (Alexander)
It seems that the ``pg_stat_replication`` view could contain NULL values in the ``replay_lsn``, ``flush_lsn``, or ``write_lsn`` fields even when ``state = 'streaming'``.
Version 2.1.0
-------------
@@ -39,13 +415,13 @@ This version adds compatibility with PostgreSQL v14, makes logical replication s
When everything goes normal, only one line will be written for every run of HA loop.
**Breaking chances**
**Breaking changes**
- The old ``permanent logical replication slots`` feature will no longer work with PostgreSQL v10 and older (Alexander)
The strategy of creating the logical slots after performing a promotion can't guaranty that no logical events are lost and therefore disabled.
- The ``/leader`` always endpoint returns 200 if the node holds the lock (Alexander)
- The ``/leader`` endpoint always returns 200 if the node holds the lock (Alexander)
Promoting the standby cluster requires updating load-balancer health checks, which is not very convenient and easy to forget. To solve it, we change the behavior of the ``/leader`` health check endpoint. It will return 200 without taking into account whether the cluster is normal or the ``standby_cluster``.
+18 -6
View File
@@ -63,7 +63,7 @@ Building replicas
-----------------
Patroni uses tried and proven ``pg_basebackup`` in order to create new replicas. One downside of it is that it requires
a running master node. Another one is the lack of 'on-the-fly' compression for the backup data and no built-in cleanup
a running leader node. Another one is the lack of 'on-the-fly' compression for the backup data and no built-in cleanup
for outdated backup files. Some people prefer other backup solutions, such as ``WAL-E``, ``pgBackRest``, ``Barman`` and
others, or simply roll their own scripts. In order to accommodate all those use-cases Patroni supports running custom
scripts to clone a new replica. Those are configured in the ``postgresql`` configuration block:
@@ -123,11 +123,11 @@ to execute and any custom parameters that should be passed to that command. All
--role
Always 'replica'
--connstring
Connection string to connect to the cluster member to clone from (master or other replica). The user in the
Connection string to connect to the cluster member to clone from (primary or other replica). The user in the
connection string can execute SQL and replication protocol commands.
A special ``no_master`` parameter, if defined, allows Patroni to call the replica creation method even if there is no
running master or replicas. In that case, an empty string will be passed in a connection string. This is useful for
running leader or replicas. In that case, an empty string will be passed in a connection string. This is useful for
restoring the formerly running cluster from the binary backup.
A special ``keep_data`` parameter, if defined, will instruct Patroni to not clean PGDATA folder before calling restore.
@@ -137,7 +137,7 @@ A special ``no_params`` parameter, if defined, restricts passing parameters to c
A ``basebackup`` method is a special case: it will be used if
``create_replica_methods`` is empty, although it is possible
to list it explicitly among the ``create_replica_methods`` methods. This method initializes a new replica with the
``pg_basebackup``, the base backup is taken from the master unless there are replicas with ``clonefrom`` tag, in which case one
``pg_basebackup``, the base backup is taken from the leader unless there are replicas with ``clonefrom`` tag, in which case one
of such replicas will be used as the origin for pg_basebackup. It works without any configuration; however, it is
possible to specify a ``basebackup`` configuration section. Same rules as with the other method configuration apply,
namely, only long (with --) options should be specified there. Not all parameters make sense, if you override a connection
@@ -176,10 +176,10 @@ Standby cluster
---------------
Another available option is to run a "standby cluster", that contains only of
standby nodes replicating from some remote master. This type of clusters has:
standby nodes replicating from some remote node. This type of clusters has:
* "standby leader", that behaves pretty much like a regular cluster leader,
except it replicates from a remote master.
except it replicates from a remote node.
* cascade replicas, that are replicating from standby leader.
@@ -187,6 +187,13 @@ Standby leader holds and updates a leader lock in DCS. If the leader lock
expires, cascade replicas will perform an election to choose another leader
from the standbys.
There is no further relationship between the standby cluster and the primary
cluster it replicates from, in particular, they must not share the same DCS
scope if they use the same DCS. They do not know anything else from each other
apart from replication information. Also, the standby cluster is not being
displayed in ``patronictl list`` or ``patronictl topology`` output on the
primary cluster.
For the sake of flexibility, you can specify methods of creating a replica and
recovery WAL records when a cluster is in the "standby mode" by providing
`create_replica_methods` key in `standby_cluster` section. It is distinct from
@@ -212,4 +219,9 @@ in a patroni configuration:
Note, that these options will be applied only once during cluster bootstrap,
and the only way to change them afterwards is through DCS.
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
of the remote primary and will not start if it does not find it after a
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
your responsibility to copy it to PGDATA.
If you use replication slots on the standby cluster, you must also create the corresponding replication slot on the primary cluster. It will not be done automatically by the standby cluster implementation. You can use Patroni's permanent replication slots feature on the primary cluster to maintain a replication slot with the same name as ``primary_slot_name``, or its default value if ``primary_slot_name`` is not provided.
+1 -1
View File
@@ -13,7 +13,7 @@ In asynchronous mode the cluster is allowed to lose some committed transactions
The amount of transactions that can be lost is controlled via ``maximum_lag_on_failover`` parameter. Because the primary transaction log position is not sampled in real time, in reality the amount of lost data on failover is worst case bounded by ``maximum_lag_on_failover`` bytes of transaction log plus the amount that is written in the last ``ttl`` seconds (``loop_wait``/2 seconds in the average case). However typical steady state replication delay is well under a second.
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former master become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former primary become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
PostgreSQL synchronous replication
----------------------------------
+5 -2
View File
@@ -7,7 +7,7 @@ Patroni has a rich REST API, which is used by Patroni itself during the leader r
Health check endpoints
----------------------
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``OPTIONS`` method instead of ``GET``.
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``HEAD`` or ``OPTIONS`` method instead of ``GET``.
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the primary with leader lock:
@@ -44,8 +44,11 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /synchronous`` or ``GET /sync``: returns HTTP status code **200** only when the Patroni node is running as a synchronous standby.
- ``GET /read-only-sync``: like the above endpoint, but also includes the primary.
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
- ``GET /asynchronous?lag=<max-lag>`` or ``GET /async?lag=<max-lag>``: asynchronous standby check endpoint. In addition to checks from ``asynchronous`` or ``async``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
- ``GET /async?lag=1048576``
@@ -55,7 +58,7 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /health``: returns HTTP status code **200** only when PostgreSQL is up and running.
- ``GET /liveness``: always returns HTTP status code **200** what only indicates that Patroni is running. Could be used for ``livenessProbe``.
- ``GET /liveness``: returns HTTP status code **200** if Patroni heartbeat loop is properly running and **503** if the last run was more than ``ttl`` seconds ago on the primary or ``2*ttl`` on the replica. Could be used for ``livenessProbe``.
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubernetes endpoints for leader elections (OpenShift).
+2 -2
View File
@@ -3,7 +3,7 @@
Watchdog support
================
Having multiple PostgreSQL servers running as master can result in transactions lost due to diverging timelines. This situation is also called a split-brain problem. To avoid split-brain Patroni needs to ensure PostgreSQL will not accept any transaction commits after leader key expires in the DCS. Under normal circumstances Patroni will try to achieve this by stopping PostgreSQL when leader lock update fails for any reason. However, this may fail to happen due to various reasons:
Having multiple PostgreSQL servers running as primary can result in transactions lost due to diverging timelines. This situation is also called a split-brain problem. To avoid split-brain Patroni needs to ensure PostgreSQL will not accept any transaction commits after leader key expires in the DCS. Under normal circumstances Patroni will try to achieve this by stopping PostgreSQL when leader lock update fails for any reason. However, this may fail to happen due to various reasons:
- Patroni has crashed due to a bug, out-of-memory condition or by being accidentally killed by a system administrator.
@@ -13,7 +13,7 @@ Having multiple PostgreSQL servers running as master can result in transactions
To guarantee correct behavior under these conditions Patroni supports watchdog devices. Watchdog devices are software or hardware mechanisms that will reset the whole system when they do not get a keepalive heartbeat within a specified timeframe. This adds an additional layer of fail safe in case usual Patroni split-brain protection mechanisms fail.
Patroni will try to activate the watchdog before promoting PostgreSQL to master. If watchdog activation fails and watchdog mode is ``required`` then the node will refuse to become master. When deciding to participate in leader election Patroni will also check that watchdog configuration will allow it to become leader at all. After demoting PostgreSQL (for example due to a manual failover) Patroni will disable the watchdog again. Watchdog will also be disabled while Patroni is in paused state.
Patroni will try to activate the watchdog before promoting PostgreSQL to primary. If watchdog activation fails and watchdog mode is ``required`` then the node will refuse to become leader. When deciding to participate in leader election Patroni will also check that watchdog configuration will allow it to become leader at all. After demoting PostgreSQL (for example due to a manual failover) Patroni will disable the watchdog again. Watchdog will also be disabled while Patroni is in paused state.
By default Patroni will set up the watchdog to expire 5 seconds before TTL expires. With the default setup of ``loop_wait=10`` and ``ttl=30`` this gives HA loop at least 15 seconds (``ttl`` - ``safety_margin`` - ``loop_wait``) to complete before the system gets forcefully reset. By default accessing DCS is configured to time out after 10 seconds. This means that when DCS is unavailable, for example due to network issues, Patroni and PostgreSQL will have at least 5 seconds (``ttl`` - ``safety_margin`` - ``loop_wait`` - ``retry_timeout``) to come to a state where all client connections are terminated.
+2 -2
View File
@@ -18,14 +18,14 @@ listen stats
listen master
bind *:5000
option httpchk OPTIONS /master
option httpchk HEAD /master
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
{{end}}
listen replicas
bind *:5001
option httpchk OPTIONS /replica
option httpchk HEAD /replica
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
+4 -4
View File
@@ -14,7 +14,7 @@ Group=postgres
# Read in configuration file if it exists, otherwise proceed
EnvironmentFile=-/etc/patroni_env.conf
# the default is the user's home directory, and if you want to change it, you must provide an absolute path.
# The default is the user's home directory, and if you want to change it, you must provide an absolute path.
# WorkingDirectory=/home/sameuser
# Where to send early-startup messages from the server
@@ -32,14 +32,14 @@ ExecStart=/bin/patroni /etc/patroni.yml
# Send HUP to reload from patroni.yml
ExecReload=/bin/kill -s HUP $MAINPID
# only kill the patroni process, not it's children, so it will gracefully stop postgres
# Only kill the patroni process, not it's children, so it will gracefully stop postgres
KillMode=process
# Give a reasonable amount of time for the server to start up/shut down
TimeoutSec=30
# Do not restart the service if it crashes, we want to manually inspect database on failure
Restart=no
# Restart the service if it crashed
Restart=on-failure
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -5,7 +5,7 @@ Feature: basic replication
Given I start postgres0
Then postgres0 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 2, "synchronous_mode": true}
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "synchronous_mode": true}
Then I receive a response code 200
When I start postgres1
And I configure and start postgres2 with a tag replicatefrom postgres0
+62 -28
View File
@@ -1,7 +1,6 @@
import abc
import datetime
import os
import psycopg2
import json
import shutil
import signal
@@ -13,6 +12,9 @@ import threading
import time
import yaml
import patroni.psycopg as psycopg
from patroni.request import PatroniRequest
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
@@ -137,12 +139,24 @@ class PatroniController(AbstractController):
def _start(self):
if self.watchdog:
self.watchdog.start()
env = os.environ.copy()
if isinstance(self._context.dcs_ctl, KubernetesController):
self._context.dcs_ctl.create_pod(self._name[8:], self._scope)
os.environ['PATRONI_KUBERNETES_POD_IP'] = '10.0.0.' + self._name[-1]
return subprocess.Popen([sys.executable, '-m', 'coverage', 'run',
'--source=patroni', '-p', 'patroni.py', self._config],
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
env['PATRONI_KUBERNETES_POD_IP'] = '10.0.0.' + self._name[-1]
if os.name == 'nt':
env['BEHAVE_DEBUG'] = 'true'
patroni = subprocess.Popen([sys.executable, '-m', 'coverage', 'run',
'--source=patroni', '-p', 'patroni.py', self._config], env=env,
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
if os.name == 'nt':
patroni.terminate = self.terminate
return patroni
def terminate(self):
try:
self._context.request_executor.request('POST', self._restapi_url + '/sigterm')
except Exception:
pass
def stop(self, kill=False, timeout=15, postgres=False):
if postgres:
@@ -177,15 +191,16 @@ class PatroniController(AbstractController):
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
config['name'] = name
config['postgresql']['data_dir'] = self._data_dir
config['postgresql']['data_dir'] = self._data_dir.replace('\\', '/')
config['postgresql']['basebackup'] = [{'checkpoint': 'fast'}]
config['postgresql']['use_unix_socket'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
config['postgresql']['use_unix_socket_repl'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name)
config['postgresql']['use_unix_socket_repl'] = os.name != 'nt'
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name).replace('\\', '/')
config['postgresql']['parameters'].update({
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
'logging_collector': 'on', 'log_destination': 'csvlog',
'log_directory': self._output_dir.replace('\\', '/'),
'log_filename': name + '.log', 'log_statement': 'all', 'log_min_messages': 'debug1',
'unix_socket_directories': self._data_dir})
'unix_socket_directories': tempfile.gettempdir().replace('\\', '/')})
if 'bootstrap' in config:
config['bootstrap']['post_bootstrap'] = 'psql -w -c "SELECT 1"'
@@ -196,7 +211,7 @@ class PatroniController(AbstractController):
self.recursive_update(config, custom_config)
self.recursive_update(config, {
'bootstrap': {'dcs': {'postgresql': {'parameters': {'wal_keep_segments': 100}}}}})
'bootstrap': {'dcs': {'loop_wait': 2, 'postgresql': {'parameters': {'wal_keep_segments': 100}}}}})
if config['postgresql'].get('callbacks', {}).get('on_role_change'):
config['postgresql']['callbacks']['on_role_change'] += ' ' + str(self.__PORT)
@@ -205,16 +220,17 @@ class PatroniController(AbstractController):
user = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {})
self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user}
self._connkwargs.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
self._connkwargs.update({'host': host, 'port': self.__PORT, 'dbname': 'postgres'})
self._replication = config['postgresql'].get('authentication', config['postgresql']).get('replication', {})
self._replication.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
self._replication.update({'host': host, 'port': self.__PORT, 'dbname': 'postgres'})
self._restapi_url = 'http://{0}'.format(config['restapi']['connect_address'])
return patroni_config_path
def _connection(self):
if not self._conn or self._conn.closed != 0:
self._conn = psycopg2.connect(**self._connkwargs)
self._conn = psycopg.connect(**self._connkwargs)
self._conn.autocommit = True
return self._conn
@@ -228,7 +244,7 @@ class PatroniController(AbstractController):
cursor = self._cursor()
cursor.execute(query)
return cursor
except psycopg2.Error:
except psycopg.Error:
if not fail_ok:
raise
@@ -268,7 +284,7 @@ class PatroniController(AbstractController):
@property
def backup_source(self):
return 'postgres://{username}:{password}@{host}:{port}/{database}'.format(**self._replication)
return 'postgres://{username}:{password}@{host}:{port}/{dbname}'.format(**self._replication)
def backup(self, dest=os.path.join('data', 'basebackup')):
subprocess.call(PatroniPoolController.BACKUP_SCRIPT + ['--walmethod=none',
@@ -393,7 +409,7 @@ class AbstractEtcdController(AbstractDcsController):
self._client_cls = client_cls
def _start(self):
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
return subprocess.Popen(["etcd", "--data-dir", self._work_directory],
stdout=self._log, stderr=subprocess.STDOUT)
def _is_running(self):
@@ -625,15 +641,17 @@ class RaftController(AbstractDcsController):
self.start()
ready_event = threading.Event()
self._raft = KVStoreTTL(ready_event.set, None, None, partner_addrs=[self.CONTROLLER_ADDR], password=self.PASSWORD)
self._raft = KVStoreTTL(ready_event.set, None, None,
partner_addrs=[self.CONTROLLER_ADDR], password=self.PASSWORD)
self._raft.startAutoTick()
ready_event.wait()
class PatroniPoolController(object):
BACKUP_SCRIPT = [sys.executable, 'features/backup_create.py']
ARCHIVE_RESTORE_SCRIPT = ' '.join((sys.executable, os.path.abspath('features/archive-restore.py')))
PYTHON = sys.executable.replace('\\', '/')
BACKUP_SCRIPT = [PYTHON, 'features/backup_create.py']
ARCHIVE_RESTORE_SCRIPT = ' '.join((PYTHON, os.path.abspath('features/archive-restore.py')))
def __init__(self, context):
self._context = context
@@ -659,7 +677,7 @@ class PatroniPoolController(object):
def output_dir(self):
return self._output_dir
def start(self, name, max_wait_limit=20, custom_config=None):
def start(self, name, max_wait_limit=40, custom_config=None):
if name not in self._processes:
self._processes[name] = PatroniController(self._context, name, self.patroni_path,
self._output_dir, custom_config)
@@ -709,7 +727,7 @@ class PatroniPoolController(object):
'archive_mode': 'on',
'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive ' +
'--dirname {} --filename %f --pathname %p').format(
os.path.join(self.patroni_path, 'data', 'wal_archive'))
os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
},
'authentication': {
'superuser': {'password': 'zalando1'},
@@ -725,14 +743,14 @@ class PatroniPoolController(object):
'bootstrap': {
'method': 'backup_restore',
'backup_restore': {
'command': (sys.executable + ' features/backup_restore.py --sourcedir=' +
os.path.join(self.patroni_path, 'data', 'basebackup')),
'command': (self.PYTHON + ' features/backup_restore.py --sourcedir=' +
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'recovery_conf': {
'recovery_target_action': 'promote',
'recovery_target_timeline': 'latest',
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' +
'--dirname {} --filename %f --pathname %p').format(
os.path.join(self.patroni_path, 'data', 'wal_archive'))
os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
}
}
},
@@ -872,7 +890,10 @@ class WatchdogMonitor(object):
# actions to execute on start/stop of the tests and before running individual features
def before_all(context):
os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
context.ci = any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS'))
context.request_executor = PatroniRequest({'ctl': {'auth': os.environ['PATRONI_RESTAPI_USERNAME'] +
':' + os.environ['PATRONI_RESTAPI_PASSWORD']}})
context.ci = os.name == 'nt' or\
any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS'))
context.timeout_multiplier = 5 if context.ci else 1 # MacOS sometimes is VERY slow
context.pctl = PatroniPoolController(context)
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
@@ -892,13 +913,26 @@ def after_all(context):
def before_feature(context, feature):
""" create per-feature output directory to collect Patroni and PostgreSQL logs """
context.pctl.create_and_set_output_directory(feature.name)
if feature.name == 'watchdog' and os.name == 'nt':
feature.skip("Watchdog isn't supported on Windows")
else:
context.pctl.create_and_set_output_directory(feature.name)
def after_feature(context, feature):
""" stop all Patronis, remove their data directory and cleanup the keys in etcd """
context.pctl.stop_all()
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
data = os.path.join(context.pctl.patroni_path, 'data')
if os.path.exists(data):
shutil.rmtree(data)
context.dcs_ctl.cleanup_service_tree()
if feature.status == 'failed':
shutil.copytree(context.pctl.output_dir, context.pctl.output_dir + '_failed')
def before_scenario(context, scenario):
if 'slot-advance' in scenario.effective_tags:
for p in context.pctl._processes.values():
if p._conn and p._conn.server_version < 110000:
scenario.skip('pg_replication_slot_advance() is not supported on {0}'.format(p._conn.server_version))
break
+1 -1
View File
@@ -3,7 +3,7 @@ Feature: ignored slots
Given I start postgres1
Then postgres1 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I issue a PATCH request to http://127.0.0.1:8009/config with {"loop_wait": 2, "ignore_slots": [{"name": "unmanaged_slot_0", "database": "postgres", "plugin": "test_decoding", "type": "logical"}, {"name": "unmanaged_slot_1", "database": "postgres", "plugin": "test_decoding"}, {"name": "unmanaged_slot_2", "database": "postgres"}, {"name": "unmanaged_slot_3"}], "postgresql": {"parameters": {"wal_level": "logical"}}}
When I issue a PATCH request to http://127.0.0.1:8009/config with {"ignore_slots": [{"name": "unmanaged_slot_0", "database": "postgres", "plugin": "test_decoding", "type": "logical"}, {"name": "unmanaged_slot_1", "database": "postgres", "plugin": "test_decoding"}, {"name": "unmanaged_slot_2", "database": "postgres"}, {"name": "unmanaged_slot_3"}], "postgresql": {"parameters": {"wal_level": "logical"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8009/config contains ignore_slots after 10 seconds
# Make sure the wal_level has been changed.
+9 -8
View File
@@ -35,13 +35,13 @@ Scenario: check local configuration reload
Then I receive a response code 202
Scenario: check dynamic configuration change via DCS
Given I run patronictl.py edit-config -s 'ttl=10' -s 'loop_wait=2' -p 'max_connections=101' --force batman
Given I run patronictl.py edit-config -s 'ttl=10' -p 'max_connections=101' --force batman
Then I receive a response returncode 0
And I receive a response output "+loop_wait: 2"
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
When I issue a GET request to http://127.0.0.1:8008/config
Then I receive a response code 200
And I receive a response loop_wait 2
And I receive a response ttl 10
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 tags {'new_tag': 'new_value'}
@@ -51,11 +51,11 @@ Scenario: check the scheduled restart
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"role": "replica"}
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"}
Then I receive a response code 202
And I sleep for 4 seconds
And I sleep for 8 seconds
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"restart_pending": "True"}
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"}
Then I receive a response code 202
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
@@ -71,6 +71,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
When I run patronictl.py restart batman postgres1 --force
Then I receive a response returncode 0
Then replication works from postgres0 to postgres1 after 20 seconds
And I sleep for 2 seconds
When I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 200
And I receive a response state running
@@ -103,12 +104,12 @@ Scenario: check the switchover via the API in the pause mode
Then I receive a response code 503
Scenario: check the scheduled switchover
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
Then I receive a response returncode 1
And I receive a response output "Can't schedule switchover in the paused state"
When I run patronictl.py resume batman
Then I receive a response returncode 0
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
Then I receive a response returncode 0
And postgres0 is a leader after 20 seconds
And postgres0 role is the primary after 10 seconds
+2 -2
View File
@@ -3,7 +3,7 @@ Feature: standby cluster
Given I start postgres1
Then postgres1 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I issue a PATCH request to http://127.0.0.1:8009/config with {"loop_wait": 2, "slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8009/config contains slots after 10 seconds
And I sleep for 3 seconds
@@ -14,7 +14,7 @@ Feature: standby cluster
Then "members/postgres0" key in DCS has state=running after 10 seconds
And replication works from postgres1 to postgres0 after 15 seconds
@skip
@slot-advance
Scenario: check permanent logical slots are synced to the replica
Given I run patronictl.py restart batman postgres1 --force
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
+1 -1
View File
@@ -1,4 +1,4 @@
import psycopg2 as pg
import patroni.psycopg as pg
from behave import step, then
from time import sleep, time
+4 -4
View File
@@ -10,10 +10,8 @@ import yaml
from behave import register_type, step, then
from dateutil import tz
from datetime import datetime, timedelta
from patroni.request import PatroniRequest
tzutc = tz.tzutc()
request_executor = PatroniRequest({'ctl': {'auth': 'username:password'}})
@parse.with_pattern(r'https?://(?:\w|\.|:|/)+')
@@ -75,7 +73,9 @@ def do_post_empty(context, url):
def do_request(context, request_method, url, data):
data = data and json.loads(data)
try:
r = request_executor.request(request_method, url, data)
r = context.request_executor.request(request_method, url, data)
if request_method == 'PATCH' and r.status == 409:
r = context.request_executor.request(request_method, url, data)
except Exception:
context.status_code = context.response = None
else:
@@ -137,7 +137,7 @@ def add_tag_to_config(context, tag, value, pg_name):
def check_http_response(context, url, value, timeout, negate=False):
timeout *= context.timeout_multiplier
for _ in range(int(timeout)):
r = request_executor.request('GET', url)
r = context.request_executor.request('GET', url)
if (value in r.data.decode('utf-8')) != negate:
break
time.sleep(1)
+4 -4
View File
@@ -1,7 +1,7 @@
import time
import psycopg2
from behave import step, then
import patroni.psycopg as pg
@step('I create a logical replication slot {slot_name} on {pg_name:w} with the {plugin:w} plugin')
@@ -10,7 +10,7 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin):
output = context.pctl.query(pg_name, ("SELECT pg_create_logical_replication_slot('{0}', '{1}'),"
" current_database()").format(slot_name, plugin))
print(output.fetchone())
except psycopg2.Error as e:
except pg.Error as e:
print(e)
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@@ -24,7 +24,7 @@ def has_logical_replication_slot(context, pg_name, slot_name, plugin):
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name)
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
"{1} rather than {2}").format(slot_name, row[1], plugin)
except psycopg2.Error:
except pg.Error:
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@@ -34,7 +34,7 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name):
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
assert not row, "Found unexpected replication slot named {0}".format(slot_name)
except psycopg2.Error:
except pg.Error:
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
+9 -14
View File
@@ -1,17 +1,12 @@
import os
import sys
import time
from behave import step
select_replication_query = """
SELECT * FROM pg_catalog.pg_stat_replication
WHERE application_name = '{0}'
"""
executable = sys.executable if os.name != 'nt' else sys.executable.replace('\\', '/')
callback = executable + " features/callback2.py "
def callbacks(context, name):
return {c: '{0} features/callback2.py {1}'.format(context.pctl.PYTHON, name)
for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')}
@step('I start {name:w} in a cluster {cluster_name:w}')
@@ -19,10 +14,10 @@ def start_patroni(context, name, cluster_name):
return context.pctl.start(name, custom_config={
"scope": cluster_name,
"postgresql": {
"callbacks": {c: callback + name for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')},
"callbacks": callbacks(context, name),
"backup_restore": {
"command": (executable + " features/backup_restore.py --sourcedir=" +
os.path.join(context.pctl.patroni_path, 'data', 'basebackup'))}
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir=" +
os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
}
})
@@ -49,7 +44,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
}
},
"postgresql": {
"callbacks": {c: callback + name for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')}
"callbacks": callbacks(context, name)
}
})
return context.pctl.start(name)
@@ -57,12 +52,12 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
@step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds')
def check_replication_status(context, pg_name1, pg_name2, timeout):
bound_time = time.time() + timeout
bound_time = time.time() + timeout * context.timeout_multiplier
while time.time() < bound_time:
cur = context.pctl.query(
pg_name2,
select_replication_query.format(pg_name1),
"SELECT * FROM pg_catalog.pg_stat_replication WHERE application_name = '{0}'".format(pg_name1),
fail_ok=True
)
+2 -2
View File
@@ -1,5 +1,5 @@
FROM postgres:11
MAINTAINER Alexander Kukushkin <alexander.kukushkin@zalando.de>
FROM postgres:15
LABEL maintainer="Alexander Kukushkin <akukushkin@microsoft.com>"
RUN export DEBIAN_FRONTEND=noninteractive \
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
+4
View File
@@ -20,6 +20,10 @@ metadata:
spec:
replicas: 3
serviceName: *cluster_name
selector:
matchLabels:
application: patroni
cluster-name: *cluster_name
template:
metadata:
labels:
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python
from patroni import main
from patroni.__main__ import main
if __name__ == '__main__':
+21 -186
View File
@@ -1,142 +1,8 @@
import logging
import os
import signal
import sys
import time
from .daemon import AbstractPatroniDaemon, abstract_main
from .version import __version__
logger = logging.getLogger(__name__)
PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
class Patroni(AbstractPatroniDaemon):
def __init__(self, config):
from patroni.api import RestApiServer
from patroni.dcs import get_dcs
from patroni.ha import Ha
from patroni.postgresql import Postgresql
from patroni.request import PatroniRequest
from patroni.watchdog import Watchdog
super(Patroni, self).__init__(config)
self.version = __version__
self.dcs = get_dcs(self.config)
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self)
self.tags = self.get_tags()
self.next_run = time.time()
self.scheduled_restart = {}
def load_dynamic_configuration(self):
from patroni.exceptions import DCSError
while True:
try:
cluster = self.dcs.get_cluster()
if cluster and cluster.config and cluster.config.data:
if self.config.set_dynamic_configuration(cluster.config):
self.dcs.reload_config(self.config)
self.watchdog.reload_config(self.config)
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
self.dcs.reload_config(self.config)
break
except DCSError:
logger.warning('Can not get cluster from dcs')
time.sleep(5)
def get_tags(self):
return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property
def nofailover(self):
return bool(self.tags.get('nofailover', False))
@property
def nosync(self):
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=False):
try:
super(Patroni, self).reload_config(sighup, local)
if local:
self.tags = self.get_tags()
self.request.reload_config(self.config)
if local or self.api.reload_local_certificate():
self.api.reload_config(self.config['restapi'])
self.watchdog.reload_config(self.config)
self.postgresql.reload_config(self.config['postgresql'], sighup)
self.dcs.reload_config(self.config)
except Exception:
logger.exception('Failed to reload config_file=%s', self.config.config_file)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@property
def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self):
self.next_run += self.dcs.loop_wait
current_time = time.time()
nap_time = self.next_run - current_time
if nap_time <= 0:
self.next_run = current_time
# Release the GIL so we don't starve anyone waiting on async_executor lock
time.sleep(0.001)
# Warn user that Patroni is not keeping up
logger.warning("Loop time exceeded, rescheduling immediately.")
elif self.ha.watch(nap_time):
self.next_run = time.time()
def run(self):
self.api.start()
self.next_run = time.time()
super(Patroni, self).run()
def _run_cycle(self):
logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
self.schedule_next_run()
def _shutdown(self):
try:
self.api.shutdown()
except Exception:
logger.exception('Exception during RestApi.shutdown')
try:
self.ha.shutdown()
except Exception:
logger.exception('Exception during Ha.shutdown')
def patroni_main():
from multiprocessing import freeze_support
from patroni.validator import schema
freeze_support()
abstract_main(Patroni, schema)
MIN_PSYCOPG2 = (2, 5, 4)
def fatal(string, *args):
@@ -144,63 +10,32 @@ def fatal(string, *args):
sys.exit(1)
def check_psycopg2():
min_psycopg2 = (2, 5, 4)
min_psycopg2_str = '.'.join(map(str, min_psycopg2))
def parse_version(version):
def parse_version(version):
def _parse_version(version):
for e in version.split('.'):
try:
yield int(e)
except ValueError:
break
return tuple(_parse_version(version.split(' ')[0]))
# We pass MIN_PSYCOPG2 and parse_version as arguments to simplify usage of check_psycopg from the setup.py
def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version):
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
try:
import psycopg2
version_str = psycopg2.__version__.split(' ')[0]
version = tuple(parse_version(version_str))
if version < min_psycopg2:
fatal('Patroni requires psycopg2>={0}, but only {1} is available', min_psycopg2_str, version_str)
from psycopg2 import __version__
if _parse_version(__version__) >= _min_psycopg2:
return
version_str = __version__.split(' ')[0]
except ImportError:
fatal('Patroni requires psycopg2>={0} or psycopg2-binary', min_psycopg2_str)
version_str = None
def main():
if os.getpid() != 1:
check_psycopg2()
return patroni_main()
# Patroni started with PID=1, it looks like we are in the container
pid = 0
# Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame):
try:
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
break
elif ret[0] != pid:
logger.info('Reaped pid=%s, exit status=%s', *ret)
except OSError:
pass
def passtochild(signo, stack_frame):
if pid:
os.kill(pid, signo)
if os.name != 'nt':
signal.signal(signal.SIGCHLD, sigchld_handler)
signal.signal(signal.SIGHUP, passtochild)
signal.signal(signal.SIGQUIT, passtochild)
signal.signal(signal.SIGUSR1, passtochild)
signal.signal(signal.SIGUSR2, passtochild)
signal.signal(signal.SIGINT, passtochild)
signal.signal(signal.SIGABRT, passtochild)
signal.signal(signal.SIGTERM, passtochild)
import multiprocessing
patroni = multiprocessing.Process(target=patroni_main)
patroni.start()
pid = patroni.pid
patroni.join()
try:
from psycopg import __version__
except ImportError:
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
if version_str:
error += ', but only psycopg2=={0} is available'.format(version_str)
fatal(error)
+178 -1
View File
@@ -1,4 +1,181 @@
from patroni import main
import logging
import os
import signal
import time
from .daemon import AbstractPatroniDaemon, abstract_main
logger = logging.getLogger(__name__)
class Patroni(AbstractPatroniDaemon):
def __init__(self, config):
from .api import RestApiServer
from .dcs import get_dcs
from .ha import Ha
from .postgresql import Postgresql
from .request import PatroniRequest
from .version import __version__
from .watchdog import Watchdog
super(Patroni, self).__init__(config)
self.version = __version__
self.dcs = get_dcs(self.config)
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self)
self.tags = self.get_tags()
self.next_run = time.time()
self.scheduled_restart = {}
def load_dynamic_configuration(self):
from patroni.exceptions import DCSError
while True:
try:
cluster = self.dcs.get_cluster()
if cluster and cluster.config and cluster.config.data:
if self.config.set_dynamic_configuration(cluster.config):
self.dcs.reload_config(self.config)
self.watchdog.reload_config(self.config)
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
self.dcs.reload_config(self.config)
break
except DCSError:
logger.warning('Can not get cluster from dcs')
time.sleep(5)
def get_tags(self):
return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property
def nofailover(self):
return bool(self.tags.get('nofailover', False))
@property
def nosync(self):
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=False):
try:
super(Patroni, self).reload_config(sighup, local)
if local:
self.tags = self.get_tags()
self.request.reload_config(self.config)
if local or sighup and self.api.reload_local_certificate():
self.api.reload_config(self.config['restapi'])
self.watchdog.reload_config(self.config)
self.postgresql.reload_config(self.config['postgresql'], sighup)
self.dcs.reload_config(self.config)
except Exception:
logger.exception('Failed to reload config_file=%s', self.config.config_file)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@property
def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self):
self.next_run += self.dcs.loop_wait
current_time = time.time()
nap_time = self.next_run - current_time
if nap_time <= 0:
self.next_run = current_time
# Release the GIL so we don't starve anyone waiting on async_executor lock
time.sleep(0.001)
# Warn user that Patroni is not keeping up
logger.warning("Loop time exceeded, rescheduling immediately.")
elif self.ha.watch(nap_time):
self.next_run = time.time()
def run(self):
self.api.start()
self.next_run = time.time()
super(Patroni, self).run()
def _run_cycle(self):
logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
self.schedule_next_run()
def _shutdown(self):
try:
self.api.shutdown()
except Exception:
logger.exception('Exception during RestApi.shutdown')
try:
self.ha.shutdown()
except Exception:
logger.exception('Exception during Ha.shutdown')
def patroni_main():
from multiprocessing import freeze_support
from patroni.validator import schema
freeze_support()
abstract_main(Patroni, schema)
def main():
if os.getpid() != 1:
from . import check_psycopg
check_psycopg()
return patroni_main()
# Patroni started with PID=1, it looks like we are in the container
pid = 0
# Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame):
try:
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
break
elif ret[0] != pid:
logger.info('Reaped pid=%s, exit status=%s', *ret)
except OSError:
pass
def passtochild(signo, stack_frame):
if pid:
os.kill(pid, signo)
if os.name != 'nt':
signal.signal(signal.SIGCHLD, sigchld_handler)
signal.signal(signal.SIGHUP, passtochild)
signal.signal(signal.SIGQUIT, passtochild)
signal.signal(signal.SIGUSR1, passtochild)
signal.signal(signal.SIGUSR2, passtochild)
signal.signal(signal.SIGINT, passtochild)
signal.signal(signal.SIGABRT, passtochild)
signal.signal(signal.SIGTERM, passtochild)
import multiprocessing
patroni = multiprocessing.Process(target=patroni_main)
patroni.start()
pid = patroni.pid
patroni.join()
if __name__ == '__main__':
+66 -32
View File
@@ -2,7 +2,6 @@ import base64
import hmac
import json
import logging
import psycopg2
import time
import traceback
import dateutil.parser
@@ -18,6 +17,7 @@ from six.moves.socketserver import ThreadingMixIn
from six.moves.urllib_parse import urlparse, parse_qs
from threading import Thread
from . import psycopg
from .exceptions import PostgresConnectionException, PostgresException
from .postgresql.misc import postgres_version_to_int
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
@@ -38,6 +38,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.log_request(status_code)
def _write_response(self, status_code, body, content_type='text/html', headers=None):
# TODO: try-catch ConnectionResetError: [Errno 104] Connection reset by peer and log it in DEBUG level
self.send_response(status_code)
headers = headers or {}
if content_type:
@@ -141,7 +142,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
ignore_tags = True
elif 'replica' in path:
status_code = replica_status_code
elif 'read-only' in path:
elif 'read-only' in path and 'sync' not in path:
status_code = 200 if 200 in (primary_status_code, standby_leader_status_code) else replica_status_code
elif 'health' in path:
status_code = 200 if response.get('state') == 'running' else 503
@@ -152,6 +153,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
status_code = replica_status_code
elif path in ('/async', '/asynchronous') and not is_synchronous:
status_code = replica_status_code
elif path in ('/read-only-sync', '/read-only-synchronous'):
if 200 in (primary_status_code, standby_leader_status_code):
status_code = 200
elif is_synchronous:
status_code = replica_status_code
# check for user defined tags in query params
if not ignore_tags and status_code == 200:
@@ -180,8 +186,20 @@ class RestApiHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.do_GET(write_status_code_only=True)
def do_HEAD(self):
self.do_GET(write_status_code_only=True)
def do_GET_liveness(self):
self._write_status_code_only(200)
patroni = self.server.patroni
is_primary = patroni.postgresql.role == 'master' and patroni.postgresql.is_running()
# We can tolerate Patroni problems longer on the replica.
# On the primary the liveness probe most likely will start failing only after the leader key expired.
# It should not be a big problem because replicas will see that the primary is still alive via REST API call.
liveness_threshold = patroni.dcs.ttl * (1 if is_primary else 2)
# In maintenance mode (pause) we are fine if heartbeat loop stuck.
status_code = 200 if patroni.ha.is_paused() or patroni.next_run + liveness_threshold > time.time() else 503
self._write_status_code_only(status_code)
def do_GET_readiness(self):
patroni = self.server.patroni
@@ -282,12 +300,27 @@ class RestApiHandler(BaseHTTPRequestHandler):
metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.")
metrics.append("# TYPE patroni_cluster_unlocked gauge")
metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres['cluster_unlocked'])))
metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres.get('cluster_unlocked', 0))))
metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
metrics.append("# TYPE patroni_postgres_timeline counter")
metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0)))
metrics.append("# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully"
" by Patroni.")
metrics.append("# TYPE patroni_dcs_last_seen gauge")
metrics.append("patroni_dcs_last_seen{0} {1}".format(scope_label, postgres.get('dcs_last_seen', 0)))
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
metrics.append("# TYPE patroni_pending_restart gauge")
metrics.append("patroni_pending_restart{0} {1}"
.format(scope_label, int(patroni.postgresql.pending_restart)))
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
metrics.append("# TYPE patroni_is_paused gauge")
metrics.append("patroni_is_paused{0} {1}"
.format(scope_label, int(patroni.ha.is_paused())))
self._write_response(200, '\n'.join(metrics)+'\n', content_type='text/plain')
def _read_json_content(self, body_is_optional=False):
@@ -335,6 +368,14 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.server.patroni.sighup_handler()
self._write_response(202, 'reload scheduled')
@check_access
def do_POST_sigterm(self):
"""Only for behave testing on windows"""
if os.name == 'nt' and os.getenv('BEHAVE_DEBUG'):
self.server.patroni.api_sigterm()
self._write_response(202, 'shutdown scheduled')
@staticmethod
def parse_schedule(schedule, action):
""" parses the given schedule and validates at """
@@ -599,7 +640,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
'postmaster_start_time': row[0],
'role': 'replica' if row[1] == 0 else 'master',
'server_version': postgresql.server_version,
'cluster_unlocked': bool(not cluster or cluster.is_unlocked()),
'xlog': ({
'received_location': row[4] or row[3],
'replayed_location': row[3],
@@ -621,13 +661,17 @@ class RestApiHandler(BaseHTTPRequestHandler):
if row[7]:
result['replication'] = row[7]
return result
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
except (psycopg.Error, RetryFailedError, PostgresConnectionException):
state = postgresql.state
if state == 'running':
logger.exception('get_postgresql_status')
state = 'unknown'
return {'state': state, 'role': postgresql.role}
result = {'state': state, 'role': postgresql.role}
if not cluster or cluster.is_unlocked():
result['cluster_unlocked'] = True
result['dcs_last_seen'] = self.server.patroni.dcs.last_seen
return result
def handle_one_request(self):
self.__start_time = time.time()
@@ -646,10 +690,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
self.patroni = patroni
self.__listen = None
self.__ssl_options = None
self.reload_config(config)
self.daemon = True
self.__ssl_serial_number = None
self._received_new_cert = False
self.reload_config(config)
self.daemon = True
def query(self, sql, *params):
cursor = None
@@ -657,7 +701,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
with self.patroni.postgresql.connection().cursor() as cursor:
cursor.execute(sql, params)
return [r for r in cursor]
except psycopg2.Error as e:
except psycopg.Error as e:
if cursor and cursor.connection.closed == 0:
raise e
raise PostgresConnectionException('connection problems')
@@ -760,6 +804,8 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
reloading_config = self.__listen is not None # changing config in runtime
if reloading_config:
self.shutdown()
# Rely on ThreadingMixIn.server_close() to have all requests terminate before we continue
self.server_close()
self.__listen = listen
self.__ssl_options = ssl_options
@@ -787,32 +833,20 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
else:
logger.error('Bad value in the "restapi.verify_client": %s', verify_client)
self.__ssl_serial_number = self.get_certificate_serial_number()
self.socket = ctx.wrap_socket(self.socket, server_side=True)
self.socket = ctx.wrap_socket(self.socket, server_side=True, do_handshake_on_connect=False)
if reloading_config:
self.start()
def process_request_thread(self, request, client_address):
if isinstance(request, tuple):
sock, newsock = request
try:
request = sock.context.wrap_socket(newsock, do_handshake_on_connect=sock.do_handshake_on_connect,
suppress_ragged_eofs=sock.suppress_ragged_eofs, server_side=True)
except socket.error:
return
enable_keepalive(request, 10, 3)
if hasattr(request, 'context'): # SSLSocket
request.do_handshake()
super(RestApiServer, self).process_request_thread(request, client_address)
def get_request(self):
sock = self.socket
newsock, addr = socket.socket.accept(sock)
enable_keepalive(newsock, 10, 3)
if hasattr(sock, 'context'): # SSLSocket, we want to do the deferred handshake from a thread
newsock = (sock, newsock)
return newsock, addr
def shutdown_request(self, request):
if isinstance(request, tuple):
_, request = request # SSLSocket
return super(RestApiServer, self).shutdown_request(request)
if hasattr(request, 'context'): # SSLSocket
request.unwrap()
super(RestApiServer, self).shutdown_request(request)
def get_certificate_serial_number(self):
if self.__ssl_options.get('certfile'):
@@ -867,6 +901,6 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
@staticmethod
def handle_error(request, client_address):
address, port = client_address
logger.warning('Exception happened during processing of request from {}:{}'.format(address, port))
logger.warning('Exception happened during processing of request from %s:%s',
client_address[0], client_address[1])
logger.warning(traceback.format_exc())
+15 -12
View File
@@ -24,6 +24,7 @@ _AUTH_ALLOWED_PARAMETERS = (
'sslpassword',
'sslrootcert',
'sslcrl',
'sslcrldir',
'gssencmode',
'channel_binding'
)
@@ -31,7 +32,7 @@ _AUTH_ALLOWED_PARAMETERS = (
def default_validator(conf):
if not conf:
return "Config is empty."
raise ConfigParseError("Config is empty.")
class Config(object):
@@ -101,9 +102,9 @@ class Config(object):
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
if validator:
error = validator(self._local_configuration)
if error:
raise ConfigParseError(error)
errors = validator(self._local_configuration)
if errors:
raise ConfigParseError("\n".join(errors))
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
@@ -226,7 +227,8 @@ class Config(object):
for name, value in (value or {}).items():
if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value))
elif name not in ('connect_address', 'listen', 'data_dir', 'pgpass', 'authentication'):
elif name not in ('connect_address', 'proxy_address', 'listen',
'config_dir', 'data_dir', 'pgpass', 'authentication'):
config['postgresql'][name] = deepcopy(value)
elif name == 'standby_cluster':
for name, value in (value or {}).items():
@@ -269,8 +271,9 @@ class Config(object):
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
'https_extra_headers', 'allowlist', 'allowlist_include_members'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile'])
_set_section_values('postgresql', ['listen', 'connect_address', 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
@@ -347,17 +350,17 @@ class Config(object):
if param.startswith(PATRONI_ENV_PREFIX):
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
name, suffix = (param[8:].split('_', 1) + [''])[:2]
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'URL', 'PROXY',
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'SRV_SUFFIX', 'URL', 'PROXY',
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT',
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS',
'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL') and name:
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP',
'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL', 'SET_ACLS') and name:
value = os.environ.pop(param)
if suffix == 'PORT':
value = value and parse_int(value)
elif suffix in ('HOSTS', 'PORTS', 'CHECKS'):
value = value and _parse_list(value)
elif suffix == 'LABELS':
elif suffix in ('LABELS', 'SET_ACLS'):
value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
value = parse_bool(value)
+29 -15
View File
@@ -60,6 +60,18 @@ class PatronictlPrettyTable(PrettyTable):
self.__hline_num = 0
self.__hline = None
def __build_header(self, line):
header = self.__table_header[:len(line) - 2]
return "".join([line[0], header, line[1 + len(header):]])
def _stringify_hrule(self, *args, **kwargs):
ret = super(PatronictlPrettyTable, self)._stringify_hrule(*args, **kwargs)
where = args[1] if len(args) > 1 else kwargs.get('where')
if where == 'top_' and self.__table_header:
ret = self.__build_header(ret)
self.__hline_num += 1
return ret
def _is_first_hline(self):
return self.__hline_num == 0
@@ -71,8 +83,7 @@ class PatronictlPrettyTable(PrettyTable):
# Inject nice table header
if self._is_first_hline() and self.__table_header:
header = self.__table_header[:len(ret) - 2]
ret = "".join([ret[0], header, ret[1 + len(header):]])
ret = self.__build_header(ret)
self.__hline_num += 1
return ret
@@ -264,13 +275,13 @@ def get_cursor(cluster, connect_parameters, role='master', member=None):
params = member.conn_kwargs(connect_parameters)
params.update({'fallback_application_name': 'Patroni ctl', 'connect_timeout': '5'})
if 'database' in connect_parameters:
params['database'] = connect_parameters['database']
if 'dbname' in connect_parameters:
params['dbname'] = connect_parameters['dbname']
else:
params.pop('database')
params.pop('dbname')
import psycopg2
conn = psycopg2.connect(**params)
from . import psycopg
conn = psycopg.connect(**params)
conn.autocommit = True
cursor = conn.cursor()
if role == 'any':
@@ -401,7 +412,7 @@ def query(
if password:
connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str)
if dbname:
connect_parameters['database'] = dbname
connect_parameters['dbname'] = dbname
if p_file is not None:
command = p_file.read()
@@ -418,7 +429,7 @@ def query(
def query_member(cluster, cursor, member, role, command, connect_parameters):
import psycopg2
from . import psycopg
try:
if cursor is None:
cursor = get_cursor(cluster, connect_parameters, role=role, member=member)
@@ -433,11 +444,11 @@ def query_member(cluster, cursor, member, role, command, connect_parameters):
cursor.execute(command)
return cursor.fetchall(), [d.name for d in cursor.description]
except (psycopg2.OperationalError, psycopg2.DatabaseError) as oe:
logging.debug(oe)
except psycopg.DatabaseError as de:
logging.debug(de)
if cursor is not None and not cursor.connection.closed:
cursor.connection.close()
message = oe.pgcode or oe.pgerror or str(oe)
message = de.diag.sqlstate or str(de)
message = message.replace('\n', ' ')
return [[timestamp(0), 'ERROR, SQLSTATE: {0}'.format(message)]], None
@@ -1299,10 +1310,13 @@ def version(obj, cluster_name, member_names):
def history(obj, cluster_name, fmt):
cluster = get_dcs(obj, cluster_name).get_cluster()
history = cluster.history and cluster.history.lines or []
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
for line in history:
if len(line) < 4:
line.append('')
print_output(['TL', 'LSN', 'Reason', 'Timestamp'], history, {'TL': 'r', 'LSN': 'r'}, fmt)
if len(line) < len(table_header_row):
add_column_num = len(table_header_row) - len(line)
for _ in range(add_column_num):
line.append('')
print_output(table_header_row, history, {'TL': 'r', 'LSN': 'r'}, fmt)
def format_pg_version(version):
+13 -5
View File
@@ -1,3 +1,5 @@
from __future__ import print_function
import abc
import os
import signal
@@ -22,11 +24,15 @@ class AbstractPatroniDaemon(object):
def sighup_handler(self, *args):
self._received_sighup = True
def sigterm_handler(self, *args):
def api_sigterm(self):
with self._sigterm_lock:
if not self._received_sigterm:
self._received_sigterm = True
sys.exit()
return True
def sigterm_handler(self, *args):
if self.api_sigterm():
sys.exit()
def setup_signal_handlers(self):
self._received_sighup = False
@@ -83,16 +89,18 @@ def abstract_main(cls, validator=None):
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:
if validator and args.validate_config:
if validate_config:
Config(args.configfile, validator=validator)
sys.exit()
config = Config(args.configfile)
except ConfigParseError as e:
if e.value:
print(e.value)
parser.print_help()
print(e.value, file=sys.stderr)
if not validate_config:
parser.print_help()
sys.exit(1)
controller = cls(config)
+23 -12
View File
@@ -1,5 +1,5 @@
import abc
import dateutil
import dateutil.parser
import importlib
import inspect
import json
@@ -160,7 +160,7 @@ class Member(namedtuple('Member', 'index,name,session,data')):
defaults = {
"host": None,
"port": None,
"database": None
"dbname": None
}
ret = self.data.get('conn_kwargs')
if ret:
@@ -174,7 +174,7 @@ class Member(namedtuple('Member', 'index,name,session,data')):
ret = {
'host': r.hostname,
'port': r.port or 5432,
'database': r.path[1:]
'dbname': r.path[1:]
}
self.data['conn_kwargs'] = ret.copy()
@@ -460,8 +460,12 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
:param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}
"""
@property
def leader_name(self):
return self.leader and self.leader.name
def is_unlocked(self):
return not (self.leader and self.leader.name)
return not self.leader_name
def has_member(self, member_name):
return any(m for m in self.members if m.name == member_name)
@@ -499,7 +503,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
@property
def use_slots(self):
return self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
return self.config and (self.config.data.get('postgresql') or {}).get('use_slots', True)
def get_replication_slots(self, my_name, role, nofailover, major_version, show_error=False):
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
@@ -516,7 +520,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
else:
# only manage slots for replicas that replicate from this one, except for the leader among them
slot_members = [m.name for m in self.members if use_slots and
m.replicatefrom == my_name and m.name != self.leader.name]
m.replicatefrom == my_name and m.name != self.leader_name]
permanent_slots = self.__permanent_logical_slots if use_slots and not nofailover else {}
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
@@ -585,7 +589,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
return True
if self.use_slots:
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader.name]
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name]
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
return False
@@ -652,6 +656,7 @@ class AbstractDCS(object):
self._cluster_valid_till = 0
self._cluster_thread_lock = Lock()
self._last_lsn = ''
self._last_seen = 0
self._last_status = {}
self.event = Event()
@@ -722,6 +727,10 @@ class AbstractDCS(object):
def loop_wait(self):
return self._loop_wait
@property
def last_seen(self):
return self._last_seen
@abc.abstractmethod
def _load_cluster(self):
"""Internally this method should build `Cluster` object which
@@ -744,6 +753,8 @@ class AbstractDCS(object):
self.reset_cluster()
raise
self._last_seen = int(time.time())
with self._cluster_thread_lock:
self._cluster = cluster
self._cluster_valid_till = time.time() + self.ttl
@@ -767,8 +778,7 @@ class AbstractDCS(object):
:returns: `!True` on success."""
def write_leader_optime(self, last_lsn):
if self._last_lsn != last_lsn and self._write_leader_optime(last_lsn):
self._last_lsn = last_lsn
self.write_status({self._OPTIME: last_lsn})
@abc.abstractmethod
def _write_status(self, value):
@@ -782,7 +792,8 @@ class AbstractDCS(object):
self._last_status = value
cluster = self.cluster
min_version = cluster and cluster.min_version
if min_version and min_version < (2, 1, 0):
if min_version and min_version < (2, 1, 0) and self._last_lsn != value[self._OPTIME]:
self._last_lsn = value[self._OPTIME]
self._write_leader_optime(str(value[self._OPTIME]))
@abc.abstractmethod
@@ -883,7 +894,7 @@ class AbstractDCS(object):
:param last_lsn: latest checkpoint location in bytes"""
if last_lsn:
self.write_leader_optime(last_lsn)
self.write_status({self._OPTIME: last_lsn})
return self._delete_leader()
@abc.abstractmethod
@@ -926,4 +937,4 @@ class AbstractDCS(object):
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
self.event.wait(timeout)
return self.event.isSet()
return self.event.is_set()
+44 -11
View File
@@ -227,15 +227,16 @@ class Consul(AbstractDCS):
self._last_session_refresh = 0
self.__session_checks = config.get('checks', [])
self._register_service = config.get('register_service', False)
self._previous_loop_register_service = self._register_service
self._service_tags = sorted(config.get('service_tags', []))
self._previous_loop_service_tags = self._service_tags
if self._register_service:
self._service_tags = config.get('service_tags', [])
self._service_name = service_name_from_scope_name(self._scope)
if self._scope != self._service_name:
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name,
self._scope)
self._set_service_name()
self._service_check_interval = config.get('service_check_interval', '5s')
self._service_check_tls_server_name = config.get('service_check_tls_server_name', None)
if not self._ctl:
self.create_session()
self._previous_loop_token = self._client.token
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
@@ -250,7 +251,18 @@ class Consul(AbstractDCS):
def reload_config(self, config):
super(Consul, self).reload_config(config)
self._client.reload_config(config.get('consul', {}))
consul_config = config.get('consul', {})
self._client.reload_config(consul_config)
self._previous_loop_service_tags = self._service_tags
self._service_tags = sorted(consul_config.get('service_tags', []))
should_register_service = consul_config.get('register_service', False)
if should_register_service and not self._register_service:
self._set_service_name()
self._previous_loop_register_service = self._register_service
self._register_service = should_register_service
def set_ttl(self, ttl):
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x
@@ -402,14 +414,18 @@ class Consul(AbstractDCS):
self._client.kv.delete(self.member_path)
create_member = True
if self._register_service or self._previous_loop_register_service:
try:
self.update_service(not create_member and member and member.data or {}, data)
except Exception:
logger.exception('update_service')
if not create_member and member and deep_compare(data, member.data):
return True
try:
args = {} if permanent else {'acquire': self._session}
self._client.kv.put(self.member_path, json.dumps(data, separators=(',', ':')), **args)
if self._register_service:
self.update_service(not create_member and member and member.data or {}, data)
return True
except InvalidSession:
self._session = None
@@ -418,6 +434,11 @@ class Consul(AbstractDCS):
logger.exception('touch_member')
return False
def _set_service_name(self):
self._service_name = service_name_from_scope_name(self._scope)
if self._scope != self._service_name:
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope)
@catch_consul_errors
def register_service(self, service_name, **kwargs):
logger.info('Register service %s, params %s', service_name, kwargs)
@@ -439,19 +460,27 @@ class Consul(AbstractDCS):
conn_parts = urlparse(data['conn_url'])
check = base.Check.http(api_parts.geturl(), self._service_check_interval,
deregister='{0}s'.format(self._client.http.ttl * 10))
if self._service_check_tls_server_name is not None:
check['TLSServerName'] = self._service_check_tls_server_name
tags = self._service_tags[:]
tags.append(role)
self._previous_loop_service_tags = self._service_tags
self._previous_loop_token = self._client.token
params = {
'service_id': '{0}/{1}'.format(self._scope, self._name),
'address': conn_parts.hostname,
'port': conn_parts.port,
'check': check,
'tags': tags
'tags': tags,
'enable_tag_override': True,
}
if state == 'stopped':
if state == 'stopped' or (not self._register_service and self._previous_loop_register_service):
self._previous_loop_register_service = self._register_service
return self.deregister_service(params['service_id'])
self._previous_loop_register_service = self._register_service
if role in ['master', 'replica', 'standby-leader']:
if state != 'running':
return
@@ -470,7 +499,11 @@ class Consul(AbstractDCS):
if old_data.get(key) != new_data[key]:
update = True
if force or update:
if (
force or update or self._register_service != self._previous_loop_register_service
or self._service_tags != self._previous_loop_service_tags
or self._client.token != self._previous_loop_token
):
return self._update_service(new_data)
@catch_consul_errors
+17 -10
View File
@@ -184,11 +184,12 @@ class AbstractEtcdClientWithFailover(etcd.Client):
for base_uri in machines_cache:
try:
machines = list(self._get_members(base_uri, **kwargs))
machines = list(set(self._get_members(base_uri, **kwargs)))
logger.debug("Retrieved list of machines: %s", machines)
if machines:
random.shuffle(machines)
self._update_dns_cache(self._dns_resolver.resolve_async, machines)
if not self._use_proxies:
self._update_dns_cache(self._dns_resolver.resolve_async, machines)
return machines
except Exception as e:
self.http.clear()
@@ -215,10 +216,13 @@ class AbstractEtcdClientWithFailover(etcd.Client):
return response
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
self.http.clear()
# switch to the next etcd node because we don't know exactly what happened,
# whether the key didn't received an update or there is a network problem.
if not retry and i + 1 < len(machines_cache):
self.set_base_uri(machines_cache[i + 1])
if not retry:
if len(machines_cache) == 1:
self.set_base_uri(self._base_uri) # trigger Etcd3 watcher restart
# switch to the next etcd node because we don't know exactly what happened,
# whether the key didn't received an update or there is a network problem.
elif i + 1 < len(machines_cache):
self.set_base_uri(machines_cache[i + 1])
if (isinstance(fields, dict) and fields.get("wait") == "true" and
isinstance(e, (ReadTimeoutError, ProtocolError))):
logger.debug("Watch timed out.")
@@ -268,6 +272,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time)
if nodes == 0:
self._update_machines_cache = True
self.set_base_uri(self._base_uri) # trigger Etcd3 watcher restart
raise ex
retry.sleep_func(sleeptime)
retry.update_delay()
@@ -282,13 +287,14 @@ class AbstractEtcdClientWithFailover(etcd.Client):
except DNSException:
return []
def _get_machines_cache_from_srv(self, srv):
def _get_machines_cache_from_srv(self, srv, srv_suffix=None):
"""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
'GET http://{host}:{port}/members' request (peer protocol)"""
ret = []
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r
protocol = 'https' if '-ssl' in r else 'http'
endpoint = '/members' if '-server' in r else ''
for host, port in self.get_srv_record('_etcd{0}._tcp.{1}'.format(r, srv)):
@@ -325,7 +331,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
machines_cache = []
if 'srv' in self._config:
machines_cache = self._get_machines_cache_from_srv(self._config['srv'])
machines_cache = self._get_machines_cache_from_srv(self._config['srv'], self._config.get('srv_suffix'))
if not machines_cache and 'hosts' in self._config:
machines_cache = list(self._config['hosts'])
@@ -392,8 +398,9 @@ class AbstractEtcdClientWithFailover(etcd.Client):
self._machines_cache_updated = time.time()
def set_base_uri(self, value):
logger.info('Selected new etcd server %s', value)
self._base_uri = value
if self._base_uri != value:
logger.info('Selected new etcd server %s', value)
self._base_uri = value
class EtcdClient(AbstractEtcdClientWithFailover):
+18 -7
View File
@@ -11,6 +11,7 @@ import time
import urllib3
from threading import Condition, Lock, Thread
from urllib3.exceptions import ReadTimeoutError, ProtocolError
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors
@@ -315,6 +316,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
def prefix(self, key, retry=None):
return self.range(key, prefix_range_end(key), retry)
@_handle_auth_errors
def lease_grant(self, ttl, retry=None):
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
@@ -349,7 +351,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
def deleteprefix(self, key, retry=None):
return self.deleterange(key, prefix_range_end(key), retry=retry)
def watchrange(self, key, range_end=None, start_revision=None, filters=None):
def watchrange(self, key, range_end=None, start_revision=None, filters=None, read_timeout=None):
"""returns: response object"""
params = build_range_request(key, range_end)
if start_revision is not None:
@@ -357,11 +359,11 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
params['filters'] = filters or []
kwargs = self._prepare_common_parameters(1, self.read_timeout)
request_executor = self._prepare_request(kwargs, {'create_request': params})
kwargs.update(timeout=urllib3.Timeout(connect=kwargs['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)
def watchprefix(self, key, start_revision=None, filters=None):
return self.watchrange(key, prefix_range_end(key), start_revision, filters)
def watchprefix(self, key, start_revision=None, filters=None, read_timeout=None):
return self.watchrange(key, prefix_range_end(key), start_revision, filters, read_timeout)
class KVCache(Thread):
@@ -450,7 +452,14 @@ class KVCache(Thread):
def _do_watch(self, revision):
with self._response_lock:
self._response = None
response = self._client.watchprefix(self._dcs.cluster_prefix, revision)
# We do most of requests with timeouts. The only exception /watch requests to Etcd v3.
# In order to interrupt the /watch request we do socket.shutdown() from the main thread,
# which doesn't work on Windows. Therefore we want to use the last resort, `read_timeout`.
# Setting it to TTL will help to partially mitigate the problem.
# Setting it to lower value is not nice because for idling clusters it will increase
# the numbers of interrupts and reconnects.
read_timeout = self._dcs.ttl if os.name == 'nt' else None
response = self._client.watchprefix(self._dcs.cluster_prefix, revision, read_timeout=read_timeout)
with self._response_lock:
if self._response is None:
self._response = response
@@ -472,7 +481,9 @@ class KVCache(Thread):
try:
self._do_watch(result['header']['revision'])
except Exception as e:
logger.error('watchprefix failed: %r', e)
# Following exceptions are expected on Windows because the /watch request is done with `read_timeout`
if not (os.name == 'nt' and isinstance(e, (ReadTimeoutError, ProtocolError))):
logger.error('watchprefix failed: %r', e)
finally:
with self.condition:
self._is_ready = False
@@ -617,7 +628,7 @@ class Etcd3(AbstractEtcd):
return self.retry(self._do_refresh_lease)
except (Etcd3ClientError, RetryFailedError):
logger.exception('refresh_lease')
raise Etcd3Error('Failed ro keepalive/grant lease')
raise Etcd3Error('Failed to keepalive/grant lease')
def create_lease(self):
while not self._lease:
+126 -62
View File
@@ -48,31 +48,43 @@ class K8sConfig(object):
def __init__(self):
self.pool_config = {'maxsize': 10, 'num_pools': 10} # configuration for urllib3.PoolManager
self._token_expires_at = datetime.datetime.max
self._make_headers()
def _set_token(self, token):
self._headers['authorization'] = 'Bearer ' + token
def _make_headers(self, token=None, **kwargs):
self._headers = urllib3.make_headers(user_agent=USER_AGENT, **kwargs)
if token:
self._headers['authorization'] = 'Bearer ' + token
self._set_token(token)
def load_incluster_config(self):
if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
raise self.ConfigException('Service host/port is not set.')
if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
raise self.ConfigException('Service host/port is set but empty.')
if not os.path.isfile(SERVICE_CERT_FILENAME):
raise self.ConfigException('Service certificate file does not exists.')
with open(SERVICE_CERT_FILENAME) as f:
if not f.read():
raise self.ConfigException('Cert file exists but empty.')
def _read_token_file(self):
if not os.path.isfile(SERVICE_TOKEN_FILENAME):
raise self.ConfigException('Service token file does not exists.')
with open(SERVICE_TOKEN_FILENAME) as f:
token = f.read()
if not token:
raise self.ConfigException('Token file exists but empty.')
self._make_headers(token=token)
self.pool_config['ca_certs'] = SERVICE_CERT_FILENAME
self._token_expires_at = datetime.datetime.now() + self._token_refresh_interval
return token
def load_incluster_config(self, ca_certs=SERVICE_CERT_FILENAME,
token_refresh_interval=datetime.timedelta(minutes=1)):
if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
raise self.ConfigException('Service host/port is not set.')
if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
raise self.ConfigException('Service host/port is set but empty.')
if not os.path.isfile(ca_certs):
raise self.ConfigException('Service certificate file does not exists.')
with open(ca_certs) as f:
if not f.read():
raise self.ConfigException('Cert file exists but empty.')
self.pool_config['ca_certs'] = ca_certs
self._token_refresh_interval = token_refresh_interval
token = self._read_token_file()
self._make_headers(token=token)
self._server = uri('https', (os.environ[SERVICE_HOST_ENV_NAME], os.environ[SERVICE_PORT_ENV_NAME]))
@staticmethod
@@ -107,6 +119,11 @@ class K8sConfig(object):
@property
def headers(self):
if self._token_expires_at <= datetime.datetime.now():
try:
self._set_token(self._read_token_file())
except Exception as e:
logger.error('Failed to refresh service account token: %r', e)
return self._headers.copy()
@@ -343,12 +360,12 @@ class K8sClient(object):
try:
self._load_api_servers_cache()
api_servers_cache = self.api_servers_cache
api_servers = len(api_servers)
api_servers = len(api_servers_cache)
except Exception as e:
logger.debug('Failed to update list of K8s master nodes: %r', e)
sleeptime = retry.sleeptime
remaining_time = retry.stoptime - sleeptime - time.time()
remaining_time = (retry.stoptime or time.time()) - sleeptime - time.time()
nodes, timeout, retries = self._calculate_timeouts(api_servers, remaining_time)
if nodes == 0:
self._update_api_servers_cache = True
@@ -501,6 +518,8 @@ class ObjectCache(Thread):
self._condition = condition
self._name = name # name of this pod
self._is_ready = False
self._response = None # needs to be accessible from the `kill_stream()` method
self._response_lock = Lock() # protect the `self._response` from concurrent access
self._object_cache = {}
self._object_cache_lock = Lock()
self._annotations_map = {self._dcs.leader_path: self._dcs._LEADER, self._dcs.config_path: self._dcs._CONFIG}
@@ -541,63 +560,99 @@ class ObjectCache(Thread):
with self._object_cache_lock:
return self._object_cache.get(name)
def _process_event(self, event):
ev_type = event['type']
obj = event['object']
name = obj['metadata']['name']
if ev_type in ('ADDED', 'MODIFIED'):
obj = K8sObject(obj)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
new_value = None
else:
return logger.warning('Unexpected event type: %s', ev_type)
if success and obj.get('kind') != 'Pod':
if old_value:
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
value_changed = old_value != new_value and \
(name != self._dcs.config_path or old_value is not None and new_value is not None)
if value_changed:
logger.debug('%s changed from %s to %s', name, old_value, new_value)
# Do not wake up HA loop if we run as leader and received leader object update event
if value_changed or name == self._dcs.leader_path and self._name != new_value:
self._dcs.event.set()
@staticmethod
def _finish_response(response):
try:
response.close()
finally:
response.release_conn()
def _do_watch(self, resource_version):
with self._response_lock:
self._response = None
response = self._watch(resource_version)
with self._response_lock:
if self._response is None:
self._response = response
if not self._response:
return self._finish_response(response)
for event in iter_response_objects(response):
if event['object'].get('code') == 410:
break
self._process_event(event)
def _build_cache(self):
objects = self._list()
return_type = 'V1' + objects.kind[:-4]
with self._object_cache_lock:
self._object_cache = {item.metadata.name: item for item in objects.items}
with self._condition:
self._is_ready = True
self._condition.notify()
response = self._watch(objects.metadata.resource_version)
try:
for event in iter_response_objects(response):
obj = event['object']
if obj.get('code') == 410:
break
ev_type = event['type']
name = obj['metadata']['name']
if ev_type in ('ADDED', 'MODIFIED'):
obj = K8sObject(obj)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
new_value = None
else:
logger.warning('Unexpected event type: %s', ev_type)
continue
if success and return_type != 'V1Pod':
if old_value:
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
value_changed = old_value != new_value and \
(name != self._dcs.config_path or old_value is not None and new_value is not None)
if value_changed:
logger.debug('%s changed from %s to %s', name, old_value, new_value)
# Do not wake up HA loop if we run as leader and received leader object update event
if value_changed or name == self._dcs.leader_path and self._name != new_value:
self._dcs.event.set()
self._do_watch(objects.metadata.resource_version)
finally:
with self._condition:
self._is_ready = False
response.close()
response.release_conn()
with self._response_lock:
response, self._response = self._response, None
if response:
self._finish_response(response)
def kill_stream(self):
sock = None
with self._response_lock:
if self._response:
try:
sock = self._response.connection.sock
except Exception:
sock = None
else:
self._response = False
if sock:
try:
sock.shutdown(socket.SHUT_RDWR)
sock.close()
except Exception as e:
logger.debug('Error on socket.shutdown: %r', e)
def run(self):
while True:
try:
self._build_cache()
except Exception as e:
with self._condition:
self._is_ready = False
logger.error('ObjectCache.run %r', e)
def is_ready(self):
@@ -613,13 +668,14 @@ class Kubernetes(AbstractDCS):
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
self._namespace = config.get('namespace') or 'default'
self._role_label = config.get('role_label', 'role')
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
config['namespace'] = ''
super(Kubernetes, self).__init__(config)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=KubernetesRetriableException)
self._ttl = None
try:
k8s_config.load_incluster_config()
k8s_config.load_incluster_config(ca_certs=self._ca_certs)
except k8s_config.ConfigException:
k8s_config.load_kube_config(context=config.get('context', 'local'))
@@ -871,7 +927,14 @@ class Kubernetes(AbstractDCS):
def patch_or_create(self, name, annotations, resource_version=None, patch=False, retry=True, ips=None):
if retry is True:
retry = self.retry
return self._patch_or_create(name, annotations, resource_version, patch, retry, ips)
try:
return self._patch_or_create(name, annotations, resource_version, patch, retry, ips)
except k8s_client.rest.ApiException as e:
if e.status == 409 and resource_version: # Conflict in resource_version
# Terminate watchers, it could be a sign that K8s API is in a failed state
self._kinds.kill_stream()
self._pods.kill_stream()
raise e
def patch_or_create_config(self, annotations, resource_version=None, patch=False, retry=True):
# SCOPE-config endpoint requires corresponding service otherwise it might be "cleaned" by k8s master
@@ -887,7 +950,8 @@ class Kubernetes(AbstractDCS):
if not self._api.create_namespaced_service(self._namespace, body):
return
except Exception as e:
if not isinstance(e, k8s_client.rest.ApiException) or e.status != 409: # Service already exists
# 409 - service already exists, 403 - creation forbidden
if not isinstance(e, k8s_client.rest.ApiException) or e.status not in (409, 403):
return logger.exception('create_config_service failed')
self._should_create_config_service = False
@@ -924,7 +988,7 @@ class Kubernetes(AbstractDCS):
# Try to get the latest version directly from K8s API instead of relying on async cache
try:
kind = retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
kind = _retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
except Exception as e:
logger.error('Failed to get the leader object "%s": %r', self.leader_path, e)
return False
@@ -958,7 +1022,7 @@ class Kubernetes(AbstractDCS):
'transitions': leader_observed_record.get('transitions') or '0'}
if last_lsn:
annotations[self._OPTIME] = str(last_lsn)
annotations['slots'] = json.dumps(slots) if slots else None
annotations['slots'] = json.dumps(slots) if slots else None
resource_version = kind and kind.metadata.resource_version
return self._update_leader_with_retry(annotations, resource_version, self.__ips)
@@ -1008,7 +1072,7 @@ class Kubernetes(AbstractDCS):
def touch_member(self, data, permanent=False):
cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name == self._name:
role = 'promoted' if data['role'] in ('replica', 'promoted') else 'master'
role = 'master'
elif data['state'] == 'running' and data['role'] != 'master':
role = data['role']
else:
@@ -1042,12 +1106,12 @@ class Kubernetes(AbstractDCS):
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
annotations = {self._LEADER: None}
if last_lsn:
annotations[self._OPTIME] = last_lsn
annotations[self._OPTIME] = str(last_lsn)
self.patch_or_create(self.leader_path, annotations, kind.metadata.resource_version, True, False, [])
self.reset_cluster()
def cancel_initialization(self):
self.patch_or_create_config({self._INITIALIZE: None}, self._config_resource_version, True)
return self.patch_or_create_config({self._INITIALIZE: None}, None, True)
@catch_kubernetes_errors
def delete_cluster(self):
+7 -8
View File
@@ -39,9 +39,9 @@ setattr(TCPNode, 'ip', property(resolve_host))
class SyncObjUtility(object):
def __init__(self, otherNodes, conf):
def __init__(self, otherNodes, conf, retry_timeout=10):
self._nodes = otherNodes
self._utility = TcpUtility(conf.password)
self._utility = TcpUtility(conf.password, retry_timeout/max(1, len(otherNodes)))
def executeCommand(self, command):
try:
@@ -58,11 +58,11 @@ class SyncObjUtility(object):
class DynMemberSyncObj(SyncObj):
def __init__(self, selfAddress, partnerAddrs, conf):
def __init__(self, selfAddress, partnerAddrs, conf, retry_timeout=10):
self.__early_apply_local_log = selfAddress is not None
self.applied_local_log = False
utility = SyncObjUtility(partnerAddrs, conf)
utility = SyncObjUtility(partnerAddrs, conf, retry_timeout)
members = utility.getMembers()
add_self = members and selfAddress not in members
@@ -97,7 +97,7 @@ class KVStoreTTL(DynMemberSyncObj):
self.__on_set = on_set
self.__on_delete = on_delete
self.__limb = {}
self.__retry_timeout = None
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
self_addr = config.get('self_addr')
partner_addrs = set(config.get('partner_addrs', []))
@@ -121,7 +121,7 @@ class KVStoreTTL(DynMemberSyncObj):
journalFile=(file_template + '.journal' if self_addr else None),
onReady=on_ready, dynamicMembershipChange=True)
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf)
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout)
self.__data = {}
@staticmethod
@@ -271,11 +271,10 @@ class Raft(AbstractDCS):
while True:
ready_event.wait(5)
if ready_event.isSet() or self._sync_obj.applied_local_log:
if ready_event.is_set() or self._sync_obj.applied_local_log:
break
else:
logger.info('waiting on raft')
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
def _on_set(self, key, value):
leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
+50 -18
View File
@@ -1,12 +1,14 @@
import json
import logging
import select
import six
import time
from kazoo.client import KazooClient, KazooState, KazooRetry
from kazoo.exceptions import NoNodeError, NodeExistsError, SessionExpiredError
from kazoo.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import KeeperState
from kazoo.security import make_acl
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError
@@ -49,11 +51,21 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs)
def select(self, *args, **kwargs):
"""Python3 raises `ValueError` if socket is closed, because fd == -1"""
"""
Python 3.XY may raise following exceptions if select/poll are called with an invalid socket:
- `ValueError`: because fd == -1
- `TypeError`: Invalid file descriptor: -1 (starting from kazoo 2.9)
Python 2.7 may raise the `IOError` instead of `socket.error` (starting from kazoo 2.9)
When it is appropriate we map these exceptions to `socket.error`.
"""
try:
return super(PatroniSequentialThreadingHandler, self).select(*args, **kwargs)
except ValueError as e:
raise select.error(9, str(e))
except IOError as e:
raise (select.error(e.errno, e.strerror) if six.PY2 else e)
except (TypeError, ValueError) as e:
raise (e if six.PY2 and isinstance(e, TypeError) else select.error(9, str(e)))
class PatroniKazooClient(KazooClient):
@@ -83,6 +95,19 @@ class ZooKeeper(AbstractDCS):
'cert': 'certfile', 'key': 'keyfile', 'key_password': 'keyfile_password'}
kwargs = {v: config[k] for k, v in mapping.items() if k in config}
if 'set_acls' in config:
kwargs['default_acl'] = []
for principal, permissions in config['set_acls'].items():
normalizedPermissions = [p.upper() for p in permissions]
kwargs['default_acl'].append(make_acl(scheme='x509',
credential=principal,
read='READ' in normalizedPermissions,
write='WRITE' in normalizedPermissions,
create='CREATE' in normalizedPermissions,
delete='DELETE' in normalizedPermissions,
admin='ADMIN' in normalizedPermissions,
all='ALL' in normalizedPermissions))
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
@@ -91,6 +116,7 @@ class ZooKeeper(AbstractDCS):
self._fetch_cluster = True
self._fetch_status = True
self.__last_member_data = None
self._orig_kazoo_connect = self._client._connection._connect
self._client._connection._connect = self._kazoo_connect
@@ -268,17 +294,20 @@ class ZooKeeper(AbstractDCS):
logger.exception('get_cluster')
self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly')
# The /status ZNode was updated or doesn't exist and we are not leader
elif (self._fetch_status and not self._fetch_cluster or not cluster.last_lsn
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots) and\
not (cluster.leader and cluster.leader.name == self._name):
try:
last_lsn, slots = self.get_status(cluster.leader)
# The /status ZNode was updated or doesn't exist
elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
# If current node is the leader just clear the event without fetching anything (we are updating the /status)
if cluster.leader and cluster.leader.name == self._name:
self.event.clear()
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn,
cluster.members, cluster.failover, cluster.sync, cluster.history, slots)
except Exception:
pass
else:
try:
last_lsn, slots = self.get_status(cluster.leader)
self.event.clear()
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn,
cluster.members, cluster.failover, cluster.sync, cluster.history, slots)
except Exception:
pass
return cluster
def _bypass_caches(self):
@@ -334,11 +363,11 @@ class ZooKeeper(AbstractDCS):
def touch_member(self, data, permanent=False):
cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
member_data = self.__last_member_data or member and member.data
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or
not (deep_compare(member.data.get('tags', {}), data.get('tags', {})) and
member.data.get('version') == data.get('version') and
member.data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
not (deep_compare(member_data.get('tags', {}), data.get('tags', {})) and
member_data.get('version') == data.get('version') and
member_data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
@@ -347,13 +376,15 @@ class ZooKeeper(AbstractDCS):
return False
member = None
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member:
if deep_compare(data, member.data):
if deep_compare(data, member_data):
return True
else:
try:
self._client.create_async(self.member_path, encoded_data, makepath=True,
ephemeral=not permanent).get(timeout=1)
self.__last_member_data = data
return True
except Exception as e:
if not isinstance(e, NodeExistsError):
@@ -361,6 +392,7 @@ class ZooKeeper(AbstractDCS):
return False
try:
self._client.set_async(self.member_path, encoded_data).get(timeout=1)
self.__last_member_data = data
return True
except Exception:
logger.exception('touch_member')
+141 -56
View File
@@ -2,32 +2,36 @@ import datetime
import functools
import json
import logging
import psycopg2
import six
import sys
import time
import uuid
from collections import namedtuple
from multiprocessing.pool import ThreadPool
from patroni.async_executor import AsyncExecutor, CriticalTask
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from patroni.postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
from patroni.postgresql.misc import postgres_version_to_int
from patroni.postgresql.rewind import Rewind
from patroni.utils import polling_loop, tzutc, is_standby_cluster as _is_standby_cluster, parse_int
from patroni.dcs import RemoteMember
from threading import RLock
from . import psycopg
from .async_executor import AsyncExecutor, CriticalTask
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from .postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
from .postgresql.misc import postgres_version_to_int
from .postgresql.rewind import Rewind
from .utils import polling_loop, tzutc, is_standby_cluster as _is_standby_cluster, parse_int
from .dcs import RemoteMember
logger = logging.getLogger(__name__)
class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_recovery', 'timeline',
'wal_position', 'tags', 'watchdog_failed'])):
class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_recovery',
'dcs_last_seen', 'timeline', 'wal_position',
'tags', 'watchdog_failed'])):
"""Node status distilled from API response:
member - dcs.Member object of the node
reachable - `!False` if the node is not reachable or is not responding with correct JSON
in_recovery - `!True` if pg_is_in_recovery() == true
dcs_last_seen - timestamp from JSON of last succesful communication with DCS
timeline - timeline value from JSON
wal_position - maximum value of `replayed_location` or `received_location` from JSON
tags - dictionary with values of different tags (i.e. nofailover)
@@ -37,12 +41,14 @@ class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_reco
def from_api_response(cls, member, json):
is_master = json['role'] == 'master'
timeline = json.get('timeline', 0)
dcs_last_seen = json.get('dcs_last_seen', 0)
wal = not is_master and max(json['xlog'].get('received_location', 0), json['xlog'].get('replayed_location', 0))
return cls(member, True, not is_master, timeline, wal, json.get('tags', {}), json.get('watchdog_failed', False))
return cls(member, True, not is_master, dcs_last_seen, timeline, wal,
json.get('tags', {}), json.get('watchdog_failed', False))
@classmethod
def unknown(cls, member):
return cls(member, False, None, 0, 0, {}, False)
return cls(member, False, None, 0, 0, 0, {}, False)
def failover_limitation(self):
"""Returns reason why this node can't promote or None if everything is ok."""
@@ -186,9 +192,10 @@ class Ha(object):
'version': self.patroni.version
}
# following two lines are mainly necessary for consul, to avoid creation of master service
if data['role'] == 'master' and not self.is_leader():
data['role'] = 'promoted'
proxy_url = self.state_handler.proxy_url
if proxy_url:
data['proxy_url'] = proxy_url
if self.is_leader() and not self._rewind.checkpoint_after_promote():
data['checkpoint_after_promote'] = False
tags = self.get_effective_tags()
@@ -238,7 +245,7 @@ class Ha(object):
logger.info('bootstrapped %s', msg)
cluster = self.dcs.get_cluster()
node_to_follow = self._get_node_to_follow(cluster)
return self.state_handler.follow(node_to_follow)
return self.state_handler.follow(node_to_follow) is not False
else:
logger.error('failed to bootstrap %s', msg)
self.state_handler.remove_data_directory()
@@ -270,7 +277,9 @@ class Ha(object):
else:
create_replica_methods = self.get_standby_cluster_config().get('create_replica_methods', []) \
if self.is_standby_cluster() else None
if self.state_handler.can_create_replica_without_replication_connection(create_replica_methods):
can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods)
concurrent_bootstrap = self.cluster.initialize == ""
if can_bootstrap and not concurrent_bootstrap:
msg = 'bootstrap (without leader)'
return self._async_executor.try_run_async(msg, self.clone) or 'trying to ' + msg
return 'waiting for {0}leader to bootstrap'.format('standby_' if self.is_standby_cluster() else '')
@@ -290,17 +299,30 @@ class Ha(object):
return result
def _handle_crash_recovery(self):
if not self._crash_recovery_executed and (self.cluster.is_unlocked() or self._rewind.can_rewind):
self._crash_recovery_executed = True
self._crash_recovery_started = time.time()
msg = 'doing crash recovery in a single user mode'
return self._async_executor.try_run_async(msg, self._rewind.ensure_clean_shutdown) or msg
def _handle_rewind_or_reinitialize(self):
leader = self.get_remote_master() if self.is_standby_cluster() else self.cluster.leader
if not self._rewind.rewind_or_reinitialize_needed_and_possible(leader):
return None
if self._rewind.can_rewind:
# rewind is required, but postgres wasn't shut down cleanly.
if not self.state_handler.is_running() and \
self.state_handler.controldata().get('Database cluster state') == 'in archive recovery':
msg = self._handle_crash_recovery()
if msg:
return msg
msg = 'running pg_rewind from ' + leader.name
return self._async_executor.try_run_async(msg, self._rewind.execute, args=(leader,)) or msg
# remove_data_directory_on_diverged_timelines is set
if not self.is_standby_cluster():
if self._rewind.should_remove_data_directory_on_diverged_timelines and not self.is_standby_cluster():
msg = 'reinitializing due to diverged timelines'
return self._async_executor.try_run_async(msg, self._do_reinitialize, args=(self.cluster,)) or msg
@@ -313,10 +335,7 @@ class Ha(object):
if timeout == 0:
# We are requested to prefer failing over to restarting master. But see first if there
# is anyone to fail over to.
members = self.cluster.members
if self.is_synchronous_mode():
members = [m for m in members if self.cluster.sync.matches(m.name)]
if self.is_failover_possible(members):
if self.is_failover_possible(self.cluster.members):
logger.info("Master crashed. Failing over.")
self.demote('immediate')
return 'stopped PostgreSQL to fail over after a crash'
@@ -325,13 +344,10 @@ class Ha(object):
data = self.state_handler.controldata()
logger.info('pg_controldata:\n%s\n', '\n'.join(' {0}: {1}'.format(k, v) for k, v in data.items()))
if data.get('Database cluster state') in ('in production', 'shutting down', 'in crash recovery') \
and not self._crash_recovery_executed and \
(self.cluster.is_unlocked() or self._rewind.can_rewind):
self._crash_recovery_executed = True
self._crash_recovery_started = time.time()
msg = 'doing crash recovery in a single user mode'
return self._async_executor.try_run_async(msg, self._rewind.ensure_clean_shutdown) or msg
if data.get('Database cluster state') in ('in production', 'shutting down', 'in crash recovery'):
msg = self._handle_crash_recovery()
if msg:
return msg
self.load_cluster_from_dcs()
@@ -413,9 +429,10 @@ class Ha(object):
self.state_handler.get_history(self._leader_timeline + 1):
self._rewind.trigger_check_diverged_lsn()
msg = self._handle_rewind_or_reinitialize()
if msg:
return msg
if not self.state_handler.is_starting():
msg = self._handle_rewind_or_reinitialize()
if msg:
return msg
if not self.is_paused():
self.state_handler.handle_parameter_change()
@@ -551,7 +568,7 @@ class Ha(object):
if master_timeline == 1:
if cluster_history:
self.dcs.set_history_value('[]')
elif not cluster_history or cluster_history[-1][0] != master_timeline - 1 or len(cluster_history[-1]) != 4:
elif not cluster_history or cluster_history[-1][0] != master_timeline - 1 or len(cluster_history[-1]) != 5:
cluster_history = {line[0]: line for line in cluster_history or []}
history = self.state_handler.get_history(master_timeline)
if history and self.cluster.config:
@@ -559,9 +576,11 @@ class Ha(object):
for line in history:
# enrich current history with promotion timestamps stored in DCS
if len(line) == 3 and line[0] in cluster_history \
and len(cluster_history[line[0]]) == 4 \
and len(cluster_history[line[0]]) >= 4 \
and cluster_history[line[0]][1] == line[1]:
line.append(cluster_history[line[0]][3])
if len(cluster_history[line[0]]) == 5:
line.append(cluster_history[line[0]][4])
self.dcs.set_history_value(json.dumps(history, separators=(',', ':')))
def enforce_follow_remote_master(self, message):
@@ -684,16 +703,21 @@ class Ha(object):
logger.info('Ignoring the former leader being ahead of us')
return True
def is_failover_possible(self, members):
def is_failover_possible(self, members, check_synchronous=True, cluster_lsn=None):
ret = False
cluster_timeline = self.cluster.timeline
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if check_synchronous and self.is_synchronous_mode():
members = [m for m in members if self.cluster.sync.matches(m.name)]
if members:
for st in self.fetch_nodes_statuses(members):
not_allowed_reason = st.failover_limitation()
if not_allowed_reason:
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
elif self.is_lagging(st.wal_position):
elif not isinstance(st.wal_position, six.integer_types):
logger.info('Member %s does not report wal_position', st.member.name)
elif cluster_lsn and st.wal_position < cluster_lsn or\
not cluster_lsn and self.is_lagging(st.wal_position):
logger.info('Member %s exceeds maximum replication lag', st.member.name)
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
@@ -719,6 +743,11 @@ class Ha(object):
return None
return False
# in synchronous mode when our name is not in the /sync key
# we shouldn't take any action even if the candidate is unhealthy
if self.is_synchronous_mode() and not self.cluster.sync.matches(self.state_handler.name):
return False
# find specific node and check that it is healthy
member = self.cluster.get_member(failover.candidate, fallback_to_leader=False)
if member:
@@ -777,6 +806,10 @@ class Ha(object):
return False
if self.cluster.failover:
# When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race
if self.is_synchronous_mode() and self.cluster.failover.leader and \
not self.cluster.sync.matches(self.state_handler.name):
return False
return self.manual_failover_process_no_leader()
if not self.watchdog.is_healthy:
@@ -785,7 +818,7 @@ class Ha(object):
# When in sync mode, only last known master and sync standby are allowed to promote automatically.
all_known_members = self.cluster.members + self.old_cluster.members
if self.is_synchronous_mode() and self.cluster.sync.leader:
if self.is_synchronous_mode() and self.cluster.sync and self.cluster.sync.leader:
if not self.cluster.sync.matches(self.state_handler.name):
return False
# pick between synchronous candidates so we minimize unnecessary failovers/demotions
@@ -824,23 +857,44 @@ class Ha(object):
'immediate-nolock': dict(stop='immediate', checkpoint=False, release=False, offline=False, async_req=True),
}[mode]
logger.info('Demoting self (%s)', mode)
self._rewind.trigger_check_diverged_lsn()
status = {'released': False}
def on_shutdown(checkpoint_location):
# Postmaster is still running, but pg_control already reports clean "shut down".
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
self.state_handler.set_role('demoted')
with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
status['released'] = True
self.state_handler.stop(mode_control['stop'], checkpoint=mode_control['checkpoint'],
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
on_shutdown=on_shutdown if mode_control['release'] else None,
stop_timeout=self.master_stop_timeout())
self.state_handler.set_role('demoted')
self.set_is_leader(False)
if mode_control['release']:
checkpoint_location = self.state_handler.latest_checkpoint_location() if mode == 'graceful' else None
with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
if not status['released']:
checkpoint_location = self.state_handler.latest_checkpoint_location() if mode == 'graceful' else None
with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
time.sleep(2) # Give a time to somebody to take the leader lock
if mode_control['offline']:
node_to_follow, leader = None, None
else:
cluster = self.dcs.get_cluster()
node_to_follow, leader = self._get_node_to_follow(cluster), cluster.leader
try:
cluster = self.dcs.get_cluster()
node_to_follow, leader = self._get_node_to_follow(cluster), cluster.leader
except Exception:
node_to_follow, leader = None, None
# FIXME: with mode offline called from DCS exception handler and handle_long_action_in_progress
# there could be an async action already running, calling follow from here will lead
@@ -918,7 +972,7 @@ class Ha(object):
else:
members = [m for m in self.cluster.members
if not failover.candidate or m.name == failover.candidate]
if self.is_failover_possible(members): # check that there are healthy members
if self.is_failover_possible(members, False): # check that there are healthy members
ret = self._async_executor.try_run_async('manual failover: demote', self.demote, ('graceful',))
return ret or 'manual failover: demoting myself'
else:
@@ -980,8 +1034,9 @@ class Ha(object):
if self.cluster.failover and self.cluster.failover.candidate == self.state_handler.name:
return 'waiting to become master after promote...'
self._delete_leader()
return 'removed leader lock because postgres is not running as master'
if not self.is_standby_cluster():
self._delete_leader()
return 'removed leader lock because postgres is not running as master'
if self.update_lock(True):
msg = self.process_manual_failover_from_leader()
@@ -995,13 +1050,13 @@ class Ha(object):
# in case of standby cluster we don't really need to
# enforce anything, since the leader is not a master.
# So just remind the role.
msg = 'no action. I am ({0}) the standby leader with the lock'.format(self.state_handler.name) \
msg = 'no action. I am ({0}), the standby leader with the lock'.format(self.state_handler.name) \
if self.state_handler.role == 'standby_leader' else \
'promoted self to a standby leader because i had the session lock'
return self.enforce_follow_remote_master(msg)
else:
return self.enforce_master_role(
'no action. I am ({0}) the leader with the lock'.format(self.state_handler.name),
'no action. I am ({0}), the leader with the lock'.format(self.state_handler.name),
'promoted self to leader because I had the session lock'
)
else:
@@ -1019,10 +1074,10 @@ class Ha(object):
lock_owner = self.cluster.leader and self.cluster.leader.name
if self.is_standby_cluster():
return self.follow('cannot be a real primary in a standby cluster',
'no action. I am a secondary ({0}) and following a standby leader ({1})'.format(
'no action. I am ({0}), a secondary, and following a standby leader ({1})'.format(
self.state_handler.name, lock_owner), refresh=False)
return self.follow('demoting self because I do not have the lock and I was a leader',
'no action. I am a secondary ({0}) and following a leader ({1})'.format(
'no action. I am ({0}), a secondary, and following a leader ({1})'.format(
self.state_handler.name, lock_owner), refresh=False)
def evaluate_scheduled_restart(self):
@@ -1248,6 +1303,7 @@ class Ha(object):
if not self.watchdog.activate():
logger.error('Cancelling bootstrap because watchdog activation failed')
self.cancel_initialization()
self._rewind.ensure_checkpoint_after_promote(self.wakeup)
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
self.dcs.take_leader()
@@ -1362,7 +1418,14 @@ class Ha(object):
return 'started as a secondary'
# is data directory empty?
if self.state_handler.data_directory_empty():
try:
data_directory_is_empty = self.state_handler.data_directory_empty()
data_directory_is_accessible = True
except OSError as e:
data_directory_is_accessible = False
data_directory_error = e
if not data_directory_is_accessible or data_directory_is_empty:
self.state_handler.set_role('uninitialized')
self.state_handler.stop('immediate', stop_timeout=self.patroni.config['retry_timeout'])
# In case datadir went away while we were master.
@@ -1371,8 +1434,11 @@ class Ha(object):
# is this instance the leader?
if self.has_lock():
self.release_leader_key_voluntarily()
return 'released leader key voluntarily as data dir empty and currently leader'
return 'released leader key voluntarily as data dir {0} and currently leader'.format(
'empty' if data_directory_is_accessible else 'not accessible')
if not data_directory_is_accessible:
return 'data directory is not accessible: {0}'.format(data_directory_error)
if self.is_paused():
return 'running with empty data directory'
return self.bootstrap() # new node
@@ -1438,7 +1504,9 @@ class Ha(object):
# asynchronous processes are running (should be always the case for the master)
if not self._async_executor.busy and not self.state_handler.is_starting():
create_slots = self.state_handler.slots_handler.sync_replication_slots(self.cluster,
self.patroni.nofailover)
self.patroni.nofailover,
self.patroni.replicatefrom,
self.is_paused())
if not self.state_handler.cb_called:
if not self.state_handler.is_leader():
self._rewind.trigger_check_diverged_lsn()
@@ -1446,7 +1514,7 @@ class Ha(object):
if create_slots and self.cluster.leader:
err = self._async_executor.try_run_async('copy_logical_slots',
self.state_handler.slots_handler.copy_logical_slots,
args=(self.cluster.leader, create_slots))
args=(self.cluster, create_slots))
if not err:
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
return ret
@@ -1457,7 +1525,7 @@ class Ha(object):
self.demote('offline')
return 'demoted self because DCS is not accessible and i was a leader'
return 'DCS is not accessible'
except (psycopg2.Error, PostgresConnectionException):
except (psycopg.Error, PostgresConnectionException):
return 'Error communicating with PostgreSQL. Will try again later'
finally:
if not dcs_failed:
@@ -1484,10 +1552,27 @@ class Ha(object):
# This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data.
# We probably need to something smarter here.
disable_wd = self.watchdog.disable if self.watchdog.is_running else None
status = {'deleted': False}
def _on_shutdown(checkpoint_location):
if self.is_leader():
# Postmaster is still running, but pg_control already reports clean "shut down".
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
self.dcs.delete_leader(checkpoint_location)
status['deleted'] = True
else:
self.dcs.write_leader_optime(checkpoint_location)
on_shutdown = _on_shutdown if self.is_leader() else None
self.while_not_sync_standby(lambda: self.state_handler.stop(checkpoint=False, on_safepoint=disable_wd,
on_shutdown=on_shutdown,
stop_timeout=self.master_stop_timeout()))
if not self.state_handler.is_running():
if self.is_leader():
if self.is_leader() and not status['deleted']:
checkpoint_location = self.state_handler.latest_checkpoint_location()
self.dcs.delete_leader(checkpoint_location)
self.touch_member()
+59 -26
View File
@@ -1,6 +1,5 @@
import logging
import os
import psycopg2
import re
import shlex
import shutil
@@ -23,6 +22,7 @@ from .connection import Connection, get_connection_cursor
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
from .postmaster import PostmasterProcess
from .slots import SlotsHandler
from .. import psycopg
from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
@@ -264,15 +264,15 @@ class Postgresql(object):
cursor = None
try:
cursor = self._connection.cursor()
cursor.execute(sql, params)
cursor.execute(sql, params or None)
return cursor
except psycopg2.Error as e:
except psycopg.Error as e:
if cursor and cursor.connection.closed == 0:
# When connected via unix socket, psycopg2 can't recoginze 'connection lost'
# and leaves `_cursor_holder.connection.closed == 0`, but psycopg2.OperationalError
# is still raised (what is correct). It doesn't make sense to continiue with existing
# connection and we will close it, to avoid its reuse by the `cursor` method.
if isinstance(e, psycopg2.OperationalError):
if isinstance(e, psycopg.OperationalError):
self._connection.close()
else:
raise e
@@ -327,6 +327,9 @@ class Postgresql(object):
if cluster and cluster.config and cluster.config.modify_index:
self._has_permanent_logical_slots =\
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
# We want to enable hot_standby_feedback if the replica is supposed
# to have a logical slot or in case if it is the cascading replica.
self.set_enforce_hot_standby_feedback(
self._has_permanent_logical_slots or
cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
@@ -338,7 +341,9 @@ class Postgresql(object):
cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
'received_location', 'replay_paused', 'pg_control_timeline',
'received_tli', 'slot_name', 'conninfo', 'slots'], result))
cluster_info_state['slots'] = self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
if self._has_permanent_logical_slots:
cluster_info_state['slots'] =\
self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
self._cluster_info_state = cluster_info_state
except RetryFailedError as e: # SELECT failed two times
self._cluster_info_state = {'error': str(e)}
@@ -369,7 +374,11 @@ class Postgresql(object):
return self._cluster_info_state_get('received_tli')
def is_leader(self):
return bool(self._cluster_info_state_get('timeline'))
try:
return bool(self._cluster_info_state_get('timeline'))
except PostgresConnectionException:
logger.warning('Failed to determine PostgreSQL state from the connection, falling back to cached role')
return bool(self.is_running() and self.role == 'master')
def replay_paused(self):
return self._cluster_info_state_get('replay_paused')
@@ -378,12 +387,13 @@ class Postgresql(object):
self._query('SELECT pg_catalog.pg_{0}_replay_resume()'.format(self.wal_name))
def handle_parameter_change(self):
if self.major_version >= 140000 and self.replay_paused():
if self.major_version >= 140000 and not self.is_starting() and self.replay_paused():
logger.info('Resuming paused WAL replay for PostgreSQL 14+')
self.resume_wal_replay()
def pg_control_timeline(self):
try:
return int(self.controldata().get("Latest checkpoint's TimeLineID"))
except (TypeError, ValueError):
logger.exception('Failed to parse timeline from pg_controldata output')
@@ -418,11 +428,11 @@ class Postgresql(object):
# If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint.
# In this case we want to take the LSN of previous record (switch) as the last known WAL location.
if parse_lsn(lsn) == prev and desc.strip() in ('xlog switch', 'SWITCH'):
return str(prev)
return prev
except Exception as e:
logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e)
if isinstance(checkpoint_lsn, six.integer_types):
return str(checkpoint_lsn)
return checkpoint_lsn
def is_running(self):
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
@@ -593,12 +603,13 @@ class Postgresql(object):
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
if cur.fetchone()[0]:
return 'is_in_recovery=true'
return cur.execute('CHECKPOINT')
except psycopg2.Error:
cur.execute('CHECKPOINT')
except psycopg.Error:
logger.exception('Exception during CHECKPOINT')
return 'not accessible or not healty'
def stop(self, mode='fast', block_callbacks=False, checkpoint=None, on_safepoint=None, stop_timeout=None):
def stop(self, mode='fast', block_callbacks=False, checkpoint=None,
on_safepoint=None, on_shutdown=None, stop_timeout=None):
"""Stop PostgreSQL
Supports a callback when a safepoint is reached. A safepoint is when no user backend can return a successful
@@ -606,11 +617,12 @@ class Postgresql(object):
could be added.
:param on_safepoint: This callback is called when no user backends are running.
:param on_shutdown: is called when pg_controldata starts reporting `Database cluster state: shut down`
"""
if checkpoint is None:
checkpoint = False if mode == 'immediate' else True
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint, stop_timeout)
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint, on_shutdown, stop_timeout)
if success:
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
@@ -623,7 +635,7 @@ class Postgresql(object):
self.set_state('stop failed')
return success
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint, stop_timeout):
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint, on_shutdown, stop_timeout):
postmaster = self.is_running()
if not postmaster:
if on_safepoint:
@@ -647,9 +659,25 @@ class Postgresql(object):
if on_safepoint:
# Wait for our connection to terminate so we can be sure that no new connections are being initiated
self._wait_for_connection_close(postmaster)
postmaster.wait_for_user_backends_to_close()
postmaster.wait_for_user_backends_to_close(stop_timeout)
on_safepoint()
if on_shutdown and mode in ('fast', 'smart'):
i = 0
# Wait for pg_controldata `Database cluster state:` to change to "shut down"
while postmaster.is_running():
data = self.controldata()
if data.get('Database cluster state', '') == 'shut down':
on_shutdown(self.latest_checkpoint_location())
break
elif data.get('Database cluster state', '').startswith('shut down'): # shut down in recovery
break
elif stop_timeout and i >= stop_timeout:
stop_timeout = 0
break
time.sleep(STOP_POLLING_INTERVAL)
i += STOP_POLLING_INTERVAL
try:
postmaster.wait(timeout=stop_timeout)
except TimeoutExpired:
@@ -684,7 +712,7 @@ class Postgresql(object):
while postmaster.is_running(): # Need a timeout here?
cur.execute("SELECT 1")
time.sleep(STOP_POLLING_INTERVAL)
except psycopg2.Error:
except psycopg.Error:
pass
def reload(self, block_callbacks=False):
@@ -770,7 +798,8 @@ class Postgresql(object):
return True
def get_guc_value(self, name):
cmd = [self.pgcommand('postgres'), '-D', self._data_dir, '-C', name]
cmd = [self.pgcommand('postgres'), '-D', self._data_dir, '-C', name,
'--config-file={}'.format(self.config.postgresql_conf)]
try:
data = subprocess.check_output(cmd)
if data:
@@ -809,7 +838,7 @@ class Postgresql(object):
return None, None
@contextmanager
def get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs):
def get_replication_connection_cursor(self, host=None, port=5432, **kwargs):
conn_kwargs = self.config.replication.copy()
conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'),
connect_timeout=3, replication=1, options='-c statement_timeout=2000')
@@ -843,6 +872,7 @@ class Postgresql(object):
if history[-1][0] == timeline - 1:
history_mtime = datetime.fromtimestamp(history_mtime).replace(tzinfo=tz.tzlocal())
history[-1].append(history_mtime.isoformat())
history[-1].append(self.name)
return history
except Exception:
logger.exception('Failed to read and parse %s', (history_path,))
@@ -860,20 +890,22 @@ class Postgresql(object):
if change_role:
self.__cb_pending = ACTION_NOOP
ret = True
if self.is_running():
if do_reload:
self.config.write_postgresql_conf()
if self.reload(block_callbacks=change_role) and change_role:
ret = self.reload(block_callbacks=change_role)
if ret and change_role:
self.set_role(role)
else:
self.restart(block_callbacks=change_role, role=role)
ret = self.restart(block_callbacks=change_role, role=role)
else:
self.start(timeout=timeout, block_callbacks=change_role, role=role)
ret = self.start(timeout=timeout, block_callbacks=change_role, role=role) or None
if change_role:
# TODO: postpone this until start completes, or maybe do even earlier
self.call_nowait(ACTION_ON_ROLE_CHANGE)
return True
return ret
def _wait_promote(self, wait_seconds):
for _ in polling_loop(wait_seconds):
@@ -954,7 +986,7 @@ class Postgresql(object):
with self.connection().cursor() as cursor:
cursor.execute(query)
return cursor.fetchone()[0].isoformat(sep=' ')
except psycopg2.Error:
except psycopg.Error:
return None
def last_operation(self):
@@ -1077,15 +1109,16 @@ class Postgresql(object):
for app_name, sync_state, replica_lsn in self.query(
"SELECT pg_catalog.lower(application_name), sync_state, pg_{2}_{1}_diff({0}_{1}, '0/0')::bigint"
" FROM pg_catalog.pg_stat_replication"
" WHERE state = 'streaming'"
" WHERE state = 'streaming' AND {0}_{1} IS NOT NULL"
" ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name, self.wal_name)):
member = members.get(app_name)
if member and not member.tags.get('nosync', False):
replica_list.append((member.name, sync_state, replica_lsn))
replica_list.append((member.name, sync_state, replica_lsn, bool(member.nofailover)))
max_lsn = max(replica_list, key=lambda x: x[2])[2] if len(replica_list) > 1 else int(str(self.last_operation()))
for app_name, sync_state, replica_lsn in replica_list:
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
for app_name, sync_state, replica_lsn, _ in sorted(replica_list, key=lambda x: x[3]):
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
candidates.append(app_name)
if sync_state == 'sync':
+12 -12
View File
@@ -4,10 +4,12 @@ import shlex
import tempfile
import time
from patroni.dcs import RemoteMember
from patroni.utils import deep_compare
from six import string_types
from ..dcs import RemoteMember
from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare
logger = logging.getLogger(__name__)
@@ -297,26 +299,24 @@ class Bootstrap(object):
if 'NOLOGIN' not in options and 'LOGIN' not in options:
options.append('LOGIN')
params = [name]
if password:
options.extend(['PASSWORD', '%s'])
params.extend([password, password])
options.extend(['PASSWORD', quote_literal(password)])
sql = """DO $$
BEGIN
SET local synchronous_commit = 'local';
PERFORM * FROM pg_authid WHERE rolname = %s;
PERFORM * FROM pg_catalog.pg_authid WHERE rolname = {0};
IF FOUND THEN
ALTER ROLE "{0}" WITH {1};
ALTER ROLE {1} WITH {2};
ELSE
CREATE ROLE "{0}" WITH {1};
CREATE ROLE {1} WITH {2};
END IF;
END;$$""".format(name, ' '.join(options))
END;$$""".format(quote_literal(name), quote_ident(name, self._postgresql.connection()), ' '.join(options))
self._postgresql.query('SET log_statement TO none')
self._postgresql.query('SET log_min_duration_statement TO -1')
self._postgresql.query("SET log_min_error_statement TO 'log'")
try:
self._postgresql.query(sql, *params)
self._postgresql.query(sql)
finally:
self._postgresql.query('RESET log_min_error_statement')
self._postgresql.query('RESET log_min_duration_statement')
@@ -342,8 +342,8 @@ END;$$""".format(name, ' '.join(options))
sql = """DO $$
BEGIN
SET local synchronous_commit = 'local';
GRANT EXECUTE ON function pg_catalog.{0} TO "{1}";
END;$$""".format(f, rewind['username'])
GRANT EXECUTE ON function pg_catalog.{0} TO {1};
END;$$""".format(f, quote_ident(rewind['username'], self._postgresql.connection()))
postgresql.query(sql)
for name, value in (config.get('users') or {}).items():
+69 -34
View File
@@ -12,6 +12,7 @@ from .validator import CaseInsensitiveDict, recovery_parameters,\
transform_postgresql_parameter_value, transform_recovery_parameter_value
from ..dcs import slot_name_from_member_name, RemoteMember
from ..exceptions import PatroniFatalException
from ..psycopg import quote_ident as _quote_ident
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, \
validate_directory, is_subpath
@@ -23,7 +24,7 @@ PARAMETER_RE = re.compile(r'([a-z_]+)\s*=\s*')
def quote_ident(value):
"""Very simplified version of quote_ident"""
return value if SYNC_STANDBY_NAME_RE.match(value) else '"' + value + '"'
return value if SYNC_STANDBY_NAME_RE.match(value) else _quote_ident(value)
def conninfo_uri_parse(dsn):
@@ -477,18 +478,20 @@ class ConfigHandler(object):
ret.setdefault('channel_binding', 'prefer')
if self._krbsrvname:
ret['krbsrvname'] = self._krbsrvname
if 'database' in ret:
del ret['database']
if 'dbname' in ret:
del ret['dbname']
return ret
def format_dsn(self, params, include_dbname=False):
# A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq
keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port',
'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl',
'application_name', 'krbsrvname', 'gssencmode', 'channel_binding')
'sslcrldir', 'application_name', 'krbsrvname', 'gssencmode', 'channel_binding',
'target_session_attrs')
if include_dbname:
params = params.copy()
params['dbname'] = params.get('database') or self._postgresql.database
if 'dbname' not in params:
params['dbname'] = self._postgresql.database
# we are abusing information about the necessity of dbname
# dsn should contain passfile or password only if there is no dbname in it (it is used in recovery.conf)
skip = {'passfile', 'password'}
@@ -540,6 +543,12 @@ class ConfigHandler(object):
if use_slots and not (is_remote_master and member.no_replication_slot):
primary_slot_name = member.primary_slot_name if is_remote_master else self._postgresql.name
recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name)
# We are a standby leader and are using a replication slot. Make sure we connect to
# the leader of the main cluster (in case more than one host is specified in the
# connstr) by adding 'target_session_attrs=read-write' to primary_conninfo.
if is_remote_master and 'target_sesions_attrs' not in primary_conninfo and\
self._postgresql.major_version >= 100000:
primary_conninfo['target_session_attrs'] = 'read-write'
recovery_params['primary_conninfo'] = primary_conninfo
# standby_cluster config might have different parameters, we want to override them
@@ -568,6 +577,9 @@ class ConfigHandler(object):
return self._RECOVERY_PARAMETERS - skip_params
def _read_recovery_params(self):
if self._postgresql.is_starting():
return None, False
pg_conf_mtime = mtime(self._postgresql_conf)
auto_conf_mtime = mtime(self._auto_conf)
passfile_mtime = mtime(self._passfile) if self._passfile else False
@@ -616,19 +628,19 @@ class ConfigHandler(object):
def _check_passfile(self, passfile, wanted_primary_conninfo):
# If there is a passfile in the primary_conninfo try to figure out that
# the passfile contains the line allowing connection to the given node.
# the passfile contains the line(s) allowing connection to the given node.
# We assume that the passfile was created by Patroni and therefore doing
# the full match and not covering cases when host, port or user are set to '*'
passfile_mtime = mtime(passfile)
if passfile_mtime:
try:
with open(passfile) as f:
wanted_line = self._pgpass_line(wanted_primary_conninfo).strip()
for raw_line in f:
if raw_line.strip() == wanted_line:
self._passfile = passfile
self._passfile_mtime = passfile_mtime
return True
wanted_lines = self._pgpass_line(wanted_primary_conninfo).splitlines()
file_lines = f.read().splitlines()
if set(wanted_lines) == set(file_lines):
self._passfile = passfile
self._passfile_mtime = passfile_mtime
return True
except Exception:
logger.info('Failed to read %s', passfile)
return False
@@ -641,16 +653,17 @@ class ConfigHandler(object):
elif not primary_conninfo:
return False
wal_receiver_primary_conninfo = self._postgresql.primary_conninfo()
if wal_receiver_primary_conninfo:
wal_receiver_primary_conninfo = parse_dsn(wal_receiver_primary_conninfo)
# when wal receiver is alive use primary_conninfo from pg_stat_wal_receiver for comparison
if not self._postgresql.is_starting():
wal_receiver_primary_conninfo = self._postgresql.primary_conninfo()
if wal_receiver_primary_conninfo:
primary_conninfo = wal_receiver_primary_conninfo
# There could be no password in the primary_conninfo or it is masked.
# Just copy the "desired" value in order to make comparison succeed.
if 'password' in wanted_primary_conninfo:
primary_conninfo['password'] = wanted_primary_conninfo['password']
wal_receiver_primary_conninfo = parse_dsn(wal_receiver_primary_conninfo)
# when wal receiver is alive use primary_conninfo from pg_stat_wal_receiver for comparison
if wal_receiver_primary_conninfo:
primary_conninfo = wal_receiver_primary_conninfo
# There could be no password in the primary_conninfo or it is masked.
# Just copy the "desired" value in order to make comparison succeed.
if 'password' in wanted_primary_conninfo:
primary_conninfo['password'] = wanted_primary_conninfo['password']
if 'passfile' in primary_conninfo and 'password' not in primary_conninfo \
and 'password' in wanted_primary_conninfo:
@@ -659,7 +672,7 @@ class ConfigHandler(object):
else:
return False
return all(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):
"""Returns a tuple. The first boolean element indicates that recovery params don't match
@@ -695,16 +708,19 @@ class ConfigHandler(object):
else: # empty string, primary_conninfo is not in the config
primary_conninfo[0] = {}
# when wal receiver is alive take primary_slot_name from pg_stat_wal_receiver
wal_receiver_primary_slot_name = self._postgresql.primary_slot_name()
if not wal_receiver_primary_slot_name and self._postgresql.primary_conninfo():
wal_receiver_primary_slot_name = ''
if wal_receiver_primary_slot_name is not None:
self._current_recovery_params['primary_slot_name'][0] = wal_receiver_primary_slot_name
if not self._postgresql.is_starting():
# when wal receiver is alive take primary_slot_name from pg_stat_wal_receiver
wal_receiver_primary_slot_name = self._postgresql.primary_slot_name()
if not wal_receiver_primary_slot_name and self._postgresql.primary_conninfo():
wal_receiver_primary_slot_name = ''
if wal_receiver_primary_slot_name is not None:
self._current_recovery_params['primary_slot_name'][0] = wal_receiver_primary_slot_name
# Increment the 'reload' to enforce write of postgresql.conf when joining the running postgres
required = {'restart': 0,
'reload': int(not self._postgresql.cb_called and self._postgresql.major_version >= 120000)}
'reload': int(self._postgresql.major_version >= 120000
and not self._postgresql.cb_called
and not self._postgresql.is_starting())}
def record_missmatch(mtype):
required['restart' if mtype else 'reload'] += 1
@@ -743,7 +759,12 @@ class ConfigHandler(object):
return re.sub(r'([:\\])', r'\\\1', str(value))
record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')}
return '{host}:{port}:*:{user}:{password}'.format(**record)
# 'host' could be several comma-separated hostnames, in this case
# we need to write on pgpass line per host
line = ''
for hostname in record.get('host').split(','):
line += hostname + ':{port}:*:{user}:{password}'.format(**record) + '\n'
return line.rstrip()
def write_pgpass(self, record):
line = self._pgpass_line(record)
@@ -766,6 +787,15 @@ class ConfigHandler(object):
else:
self._remove_file_if_exists(self._standby_signal)
open(self._recovery_signal, 'w').close()
def restart_required(name):
if self._postgresql.major_version >= 140000:
return False
return name == 'restore_command' or (self._postgresql.major_version < 130000
and name in ('primary_conninfo', 'primary_slot_name'))
self._current_recovery_params = {n: [v, restart_required(n), self._postgresql_conf]
for n, v in recovery_params.items()}
else:
with ConfigWriter(self._recovery_conf) as f:
os.chmod(self._recovery_conf, stat.S_IWRITE | stat.S_IREAD)
@@ -775,6 +805,7 @@ class ConfigHandler(object):
for name in (self._recovery_conf, self._standby_signal, self._recovery_signal):
self._remove_file_if_exists(name)
self._recovery_params = {}
self._current_recovery_params = None
def _sanitize_auto_conf(self):
overwrite = False
@@ -834,7 +865,7 @@ class ConfigHandler(object):
# this exercise is improving cross version compatibility and user must set the correct parameter in the config.
if self._postgresql.major_version >= 130000:
wal_keep_segments = parameters.pop('wal_keep_segments', self.CMDLINE_OPTIONS['wal_keep_segments'][0])
parameters.setdefault('wal_keep_size', str(wal_keep_segments * 16) + 'MB')
parameters.setdefault('wal_keep_size', str(int(wal_keep_segments) * 16) + 'MB')
elif self._postgresql.major_version:
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))
@@ -870,7 +901,7 @@ class ConfigHandler(object):
ret['user'] = self._superuser['username']
del ret['username']
# ensure certain Patroni configurations are available
ret.update({'database': self._postgresql.database,
ret.update({'dbname': self._postgresql.database,
'fallback_application_name': 'Patroni',
'connect_timeout': 3,
'options': '-c statement_timeout=2000'})
@@ -986,6 +1017,9 @@ class ConfigHandler(object):
if not local_connection_address_changed:
self.resolve_connection_addresses()
proxy_addr = config.get('proxy_address')
self._postgresql.proxy_url = uri('postgres', proxy_addr, self._postgresql.database) if proxy_addr else None
if conf_changed:
self.write_postgresql_conf()
@@ -1001,8 +1035,9 @@ class ConfigHandler(object):
if self._postgresql.major_version >= 90500:
time.sleep(1)
try:
pending_restart = self._postgresql.query('SELECT COUNT(*) FROM pg_catalog.pg_settings'
' WHERE pending_restart').fetchone()[0] > 0
pending_restart = self._postgresql.query(
'SELECT COUNT(*) FROM pg_catalog.pg_settings WHERE pg_catalog.lower(name) != ALL(%s)'
' AND pending_restart', [n.lower() for n in self._RECOVERY_PARAMETERS]).fetchone()[0] > 0
self._postgresql.set_pending_restart(pending_restart)
except Exception as e:
logger.warning('Exception %r when running query', e)
+4 -3
View File
@@ -1,9 +1,10 @@
import logging
import psycopg2
from contextlib import contextmanager
from threading import Lock
from .. import psycopg
logger = logging.getLogger(__name__)
@@ -20,7 +21,7 @@ class Connection(object):
def get(self):
with self._lock:
if not self._connection or self._connection.closed != 0:
self._connection = psycopg2.connect(**self._conn_kwargs)
self._connection = psycopg.connect(**self._conn_kwargs)
self._connection.autocommit = True
self.server_version = self._connection.server_version
return self._connection
@@ -40,7 +41,7 @@ class Connection(object):
@contextmanager
def get_connection_cursor(**kwargs):
conn = psycopg2.connect(**kwargs)
conn = psycopg.connect(**kwargs)
conn.autocommit = True
with conn.cursor() as cur:
yield cur
+11 -7
View File
@@ -171,8 +171,8 @@ class PostmasterProcess(psutil.Process):
else:
return not self.is_running()
def wait_for_user_backends_to_close(self):
# These regexps are cross checked against versions PostgreSQL 9.1 .. 11
def wait_for_user_backends_to_close(self, stop_timeout):
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
"walreceiver|wal sender process|walsender|walwriter|background writer|"
@@ -184,19 +184,23 @@ class PostmasterProcess(psutil.Process):
return logger.debug('Failed to get list of postmaster children')
user_backends = []
user_backends_cmdlines = []
user_backends_cmdlines = {}
for child in children:
try:
cmdline = child.cmdline()
if cmdline and not aux_proc_re.match(cmdline[0]):
user_backends.append(child)
user_backends_cmdlines.append(cmdline[0])
user_backends_cmdlines[child.pid] = cmdline[0]
except psutil.NoSuchProcess:
pass
if user_backends:
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines))
psutil.wait_procs(user_backends)
logger.debug("Backends closed")
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values()))
gone, live = psutil.wait_procs(user_backends, stop_timeout)
if stop_timeout and live:
live = [user_backends_cmdlines[b.pid] for b in live]
logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live))
else:
logger.debug("Backends closed")
@staticmethod
def start(pgcommand, data_dir, conf, options):
+141 -41
View File
@@ -1,6 +1,8 @@
import logging
import os
import re
import shlex
import shutil
import six
import subprocess
@@ -28,13 +30,17 @@ class Rewind(object):
def configuration_allows_rewind(data):
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
@property
def enabled(self):
return self._postgresql.config.get('use_pg_rewind')
@property
def can_rewind(self):
""" check if pg_rewind executable is there and that pg_controldata indicates
we have either wal_log_hints or checksums turned on
"""
# low-hanging fruit: check if pg_rewind configuration is there
if not self._postgresql.config.get('use_pg_rewind'):
if not self.enabled:
return False
cmd = [self._postgresql.pgcommand('pg_rewind'), '--help']
@@ -46,9 +52,13 @@ class Rewind(object):
return False
return self.configuration_allows_rewind(self._postgresql.controldata())
@property
def should_remove_data_directory_on_diverged_timelines(self):
return self._postgresql.config.get('remove_data_directory_on_diverged_timelines')
@property
def can_rewind_or_reinitialize_allowed(self):
return self._postgresql.config.get('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):
if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED:
@@ -65,6 +75,20 @@ class Rewind(object):
except Exception:
return logger.exception('Exception when working with leader')
@staticmethod
def check_leader_has_run_checkpoint(conn_kwargs):
try:
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()" +
" AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name(" +
" pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id" +
" FROM pg_catalog.pg_control_checkpoint()")
if not cur.fetchone()[0]:
return 'leader has not run a checkpoint yet'
except Exception:
logger.exception('Exception when working with leader')
return 'not accessible or not healty'
def _get_checkpoint_end(self, timeline, lsn):
"""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
@@ -98,7 +122,7 @@ class Rewind(object):
in_recovery = timeline = lsn = None
data = self._postgresql.controldata()
try:
if data.get('Database cluster state') == 'shut down in recovery':
if data.get('Database cluster state') in ('shut down in recovery', 'in archive recovery'):
in_recovery = True
lsn = data.get('Minimum recovery ending location')
timeline = int(data.get("Min recovery ending loc's timeline"))
@@ -152,8 +176,12 @@ class Rewind(object):
def _conn_kwargs(self, member, auth):
ret = member.conn_kwargs(auth)
if not ret.get('database'):
ret['database'] = self._postgresql.database
if not ret.get('dbname'):
ret['dbname'] = self._postgresql.database
# Add target_session_attrs in case more than one hostname is specified
# (libpq client-side failover) making sure we hit the primary
if 'target_session_attrs' not in ret and self._postgresql.major_version >= 100000:
ret['target_session_attrs'] = 'read-write'
return ret
def _check_timeline_and_lsn(self, leader):
@@ -161,11 +189,17 @@ class Rewind(object):
if local_timeline is None or local_lsn is None:
return
if isinstance(leader, Leader):
if leader.member.data.get('role') != 'master':
return
# standby cluster
elif not self.check_leader_is_not_in_recovery(self._conn_kwargs(leader, self._postgresql.config.replication)):
if isinstance(leader, Leader) and leader.member.data.get('role') != 'master':
return
# We want to use replication credentials when connecting to the "postgres" database in case if
# `use_pg_rewind` isn't enabled and only `remove_data_directory_on_diverged_timelines` is set
# for Postgresql older than v11 (where Patroni can't use a dedicated user for rewind).
# In all other cases we will use rewind or superuser credentials.
check_credentials = self._postgresql.config.replication if not self.enabled and\
self.should_remove_data_directory_on_diverged_timelines and\
self._postgresql.major_version < 110000 else self._postgresql.config.rewind_credentials
if not self.check_leader_is_not_in_recovery(self._conn_kwargs(leader, check_credentials)):
return
history = need_rewind = None
@@ -179,7 +213,7 @@ class Rewind(object):
elif local_timeline == master_timeline:
need_rewind = False
elif master_timeline > 1:
cur.execute('TIMELINE_HISTORY %s', (master_timeline,))
cur.execute('TIMELINE_HISTORY {0}'.format(master_timeline))
history = cur.fetchone()[1]
if not isinstance(history, six.string_types):
history = bytes(history).decode('utf-8')
@@ -202,7 +236,10 @@ class Rewind(object):
need_rewind = switchpoint != self._get_checkpoint_end(local_timeline, local_lsn)
break
elif parent_timeline > local_timeline:
need_rewind = True
break
else:
need_rewind = True
self._log_master_history(history, i)
self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
@@ -230,42 +267,49 @@ class Rewind(object):
with self._checkpoint_task_lock:
if self._checkpoint_task:
with self._checkpoint_task:
if self._checkpoint_task.result:
if self._checkpoint_task.result is not None:
self._state = REWIND_STATUS.CHECKPOINT
if self._checkpoint_task.result is not False:
return
self._checkpoint_task = None
elif self._postgresql.get_master_timeline() == self._postgresql.pg_control_timeline():
self._state = REWIND_STATUS.CHECKPOINT
else:
self._checkpoint_task = CriticalTask()
return Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
if self._postgresql.get_master_timeline() == self._postgresql.pg_control_timeline():
self._state = REWIND_STATUS.CHECKPOINT
Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
def checkpoint_after_promote(self):
return self._state == REWIND_STATUS.CHECKPOINT
def _fetch_missing_wal(self, restore_command, wal_filename):
def _buid_archiver_command(self, command, wal_filename):
"""Replace placeholders in the given archiver command's template.
Applicable for archive_command and restore_command.
Can also be used for archive_cleanup_command and recovery_end_command,
however %r value is always set to 000000010000000000000001."""
cmd = ''
length = len(restore_command)
length = len(command)
i = 0
while i < length:
if restore_command[i] == '%' and i + 1 < length:
if command[i] == '%' and i + 1 < length:
i += 1
if restore_command[i] == 'p':
if command[i] == 'p':
cmd += os.path.join(self._postgresql.wal_dir, wal_filename)
elif restore_command[i] == 'f':
elif command[i] == 'f':
cmd += wal_filename
elif restore_command[i] == 'r':
elif command[i] == 'r':
cmd += '000000010000000000000001'
elif restore_command[i] == '%':
elif command[i] == '%':
cmd += '%'
else:
cmd += '%'
i -= 1
else:
cmd += restore_command[i]
cmd += command[i]
i += 1
return cmd
def _fetch_missing_wal(self, restore_command, wal_filename):
cmd = self._buid_archiver_command(restore_command, wal_filename)
logger.info('Trying to fetch the missing wal: %s', cmd)
return self._postgresql.cancellable.call(shlex.split(cmd)) == 0
@@ -282,6 +326,42 @@ class Rewind(object):
if waldir.endswith('/pg_' + self._postgresql.wal_name) and len(wal_filename) == 24:
return wal_filename
def _archive_ready_wals(self):
"""Try to archive WALs that have .ready files just in case
archive_mode was not set to 'always' before promote, while
after it the WALs were recycled on the promoted replica.
With this we prevent the entire loss of such WALs and the
consequent old leader's start failure."""
archive_mode = self._postgresql.get_guc_value('archive_mode')
archive_cmd = self._postgresql.get_guc_value('archive_command')
if archive_mode not in ('on', 'always') or not archive_cmd:
return
walseg_regex = re.compile(r'^[0-9A-F]{24}(\.partial){0,1}\.ready$')
status_dir = os.path.join(self._postgresql.wal_dir, 'archive_status')
try:
wals_to_archive = [f[:-6] for f in os.listdir(status_dir) if walseg_regex.match(f)]
except OSError as e:
return logger.error('Unable to list %s: %r', status_dir, e)
# skip fsync, as postgres --single or pg_rewind will anyway run it
for wal in sorted(wals_to_archive):
old_name = os.path.join(status_dir, wal + '.ready')
# wal file might have alredy been archived
if os.path.isfile(old_name) and os.path.isfile(os.path.join(self._postgresql.wal_dir, wal)):
cmd = self._buid_archiver_command(archive_cmd, wal)
# it is the author of archive_command, who is responsible
# for not overriding the WALs already present in archive
logger.info('Trying to archive %s: %s', wal, cmd)
if self._postgresql.cancellable.call(shlex.split(cmd)) == 0:
new_name = os.path.join(status_dir, wal + '.done')
try:
shutil.move(old_name, new_name)
except Exception as e:
logger.error('Unable to rename %s to %s: %r', old_name, new_name, e)
else:
logger.info('Failed to archive WAL segment %s', wal)
def pg_rewind(self, r):
# prepare pg_rewind connection
env = self._postgresql.config.write_pgpass(r)
@@ -292,9 +372,18 @@ class Rewind(object):
restore_command = self._postgresql.config.get('recovery_conf', {}).get('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
pg_rewind_can_restore = restore_command and (self._postgresql.major_version >= 150000 or
(self._postgresql.major_version >= 130000 and
self._postgresql.config._config_dir == self._postgresql.data_dir))
cmd = [self._postgresql.pgcommand('pg_rewind')]
if self._postgresql.major_version >= 130000 and restore_command:
if pg_rewind_can_restore:
cmd.append('--restore-target-wal')
if self._postgresql.major_version >= 150000 and\
self._postgresql.config._config_dir != self._postgresql.data_dir:
cmd.append('--config-file={0}'.format(self._postgresql.config.postgresql_conf))
cmd.extend(['-D', self._postgresql.data_dir, '--source-server', dsn])
while True:
@@ -310,7 +399,7 @@ class Rewind(object):
if ret == 0:
return True
if not restore_command or self._postgresql.major_version >= 130000:
if not restore_command or pg_rewind_can_restore:
return False
missing_wal = self._find_missing_wal(results['stderr']) or self._find_missing_wal(results['stdout'])
@@ -325,6 +414,8 @@ class Rewind(object):
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')
self._archive_ready_wals()
# prepare pg_rewind connection
r = self._conn_kwargs(leader, self._postgresql.config.rewind_credentials)
@@ -333,9 +424,14 @@ class Rewind(object):
# running a checkpoint or
# waiting until Patroni on the master will expose checkpoint_after_promote=True
checkpoint_status = leader.checkpoint_after_promote if isinstance(leader, Leader) else None
if checkpoint_status is None: # master still runs the old Patroni
leader_status = self._postgresql.checkpoint(self._conn_kwargs(leader, self._postgresql.config.superuser))
if leader_status:
if checkpoint_status is None: # we are the standby-cluster leader or master still runs the old Patroni
# 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:
leader_status = self._postgresql.checkpoint(
self._conn_kwargs(leader, self._postgresql.config.superuser))
else: # we run 11+ and have a dedicated pg_rewind user
leader_status = self.check_leader_has_run_checkpoint(r)
if leader_status: # we tried to run/check for a checkpoint on the remote leader, but it failed
return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status)
elif not checkpoint_status:
return logger.info('Waiting for checkpoint on %s before rewind', leader.name)
@@ -344,19 +440,22 @@ class Rewind(object):
if self.pg_rewind(r):
self._state = REWIND_STATUS.SUCCESS
elif not self.check_leader_is_not_in_recovery(r):
logger.warning('Failed to rewind because master %s become unreachable', leader.name)
else:
logger.error('Failed to rewind from healty master: %s', leader.name)
for name in ('remove_data_directory_on_rewind_failure', 'remove_data_directory_on_diverged_timelines'):
if self._postgresql.config.get(name):
logger.warning('%s is set. removing...', name)
self._postgresql.remove_data_directory()
self._state = REWIND_STATUS.INITIAL
break
if not self.check_leader_is_not_in_recovery(r):
logger.warning('Failed to rewind because master %s become unreachable', leader.name)
if not self.can_rewind: # It is possible that the previous attempt damaged pg_control file!
self._state = REWIND_STATUS.FAILED
else:
logger.error('Failed to rewind from healty master: %s', leader.name)
self._state = REWIND_STATUS.FAILED
if self.failed:
for name in ('remove_data_directory_on_rewind_failure', 'remove_data_directory_on_diverged_timelines'):
if self._postgresql.config.get(name):
logger.warning('%s is set. removing...', name)
self._postgresql.remove_data_directory()
self._state = REWIND_STATUS.INITIAL
break
return False
def reset_state(self):
@@ -415,6 +514,7 @@ class Rewind(object):
logger.exception('Unable to list %s', status_dir)
def ensure_clean_shutdown(self):
self._archive_ready_wals()
self.cleanup_archive_status()
# Start in a single user mode and stop to produce a clean shutdown
+160 -41
View File
@@ -5,10 +5,11 @@ import shutil
from collections import defaultdict
from contextlib import contextmanager
from psycopg2.errors import UndefinedFile
from threading import Condition, Thread
from .connection import get_connection_cursor
from .misc import format_lsn
from ..psycopg import OperationalError
logger = logging.getLogger(__name__)
@@ -31,12 +32,96 @@ def fsync_dir(path):
os.close(fd)
class SlotsAdvanceThread(Thread):
def __init__(self, slots_handler):
super(SlotsAdvanceThread, self).__init__()
self.daemon = True
self._slots_handler = slots_handler
# _copy_slots and _failed are used to asynchronously give some feedback to the main thread
self._copy_slots = []
self._failed = False
self._scheduled = defaultdict(dict) # {'dbname1': {'slot1': 100, 'slot2': 100}, 'dbname2': {'slot3': 100}}
self._condition = Condition() # protect self._scheduled from concurrent access and to wakeup the run() method
self.start()
def sync_slot(self, cur, database, slot, lsn):
failed = copy = False
try:
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", (slot, format_lsn(lsn)))
except Exception as e:
logger.error("Failed to advance logical replication slot '%s': %r", slot, e)
failed = True
copy = isinstance(e, OperationalError) and e.diag.sqlstate == '58P01' # WAL file is gone
with self._condition:
if self._scheduled and failed:
if copy and slot not in self._copy_slots:
self._copy_slots.append(slot)
self._failed = True
new_lsn = self._scheduled.get(database, {}).get(slot, 0)
# remove slot from the self._scheduled structure only if it wasn't changed
if new_lsn == lsn and database in self._scheduled:
self._scheduled[database].pop(slot)
if not self._scheduled[database]:
self._scheduled.pop(database)
def sync_slots_in_database(self, database, slots):
with self._slots_handler.get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur:
for slot in slots:
with self._condition:
lsn = self._scheduled.get(database, {}).get(slot, 0)
if lsn:
self.sync_slot(cur, database, slot, lsn)
def sync_slots(self):
with self._condition:
databases = list(self._scheduled.keys())
for database in databases:
with self._condition:
slots = list(self._scheduled.get(database, {}).keys())
if slots:
try:
self.sync_slots_in_database(database, slots)
except Exception as e:
logger.error('Failed to advance replication slots in database %s: %r', database, e)
def run(self):
while True:
with self._condition:
if not self._scheduled:
self._condition.wait()
self.sync_slots()
def schedule(self, advance_slots):
with self._condition:
for database, values in advance_slots.items():
self._scheduled[database].update(values)
ret = (self._failed, self._copy_slots)
self._copy_slots = []
self._failed = False
self._condition.notify()
return ret
def on_promote(self):
with self._condition:
self._scheduled.clear()
self._failed = False
self._copy_slots = []
class SlotsHandler(object):
def __init__(self, postgresql):
self._postgresql = postgresql
self._advance = None
self._replication_slots = {} # already existing replication slots
self._unready_logical_slots = set()
self._unready_logical_slots = {}
self.schedule()
def _query(self, sql, *params):
@@ -82,8 +167,9 @@ class SlotsHandler(object):
replication_slots = {}
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
if self._postgresql.major_version >= 100000 else ""
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid'
'{0} FROM pg_catalog.pg_replication_slots'.format(extra))
'{0} FROM pg_catalog.pg_replication_slots{1}'.format(extra, skip_temp_slots))
for r in cursor:
value = {'type': r[1]}
if r[1] == 'logical':
@@ -94,7 +180,7 @@ class SlotsHandler(object):
self._replication_slots = replication_slots
self._schedule_load_slots = False
if self._force_readiness_check:
self._unready_logical_slots = set(n for n, v in replication_slots.items() if v['type'] == 'logical')
self._unready_logical_slots = {n: None for n, v in replication_slots.items() if v['type'] == 'logical'}
self._force_readiness_check = False
def ignore_replication_slot(self, cluster, name):
@@ -111,10 +197,10 @@ class SlotsHandler(object):
# In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active
return cursor.rowcount == 1
def _drop_incorrect_slots(self, cluster, slots):
def _drop_incorrect_slots(self, cluster, slots, paused):
# drop old replication slots which are not presented in desired slots
for name in set(self._replication_slots) - set(slots):
if not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
if not paused and not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True
@@ -142,9 +228,9 @@ class SlotsHandler(object):
self._schedule_load_slots = True
@contextmanager
def _get_local_connection_cursor(self, database):
def get_local_connection_cursor(self, **kwargs):
conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs['database'] = database
conn_kwargs.update(kwargs)
with get_connection_cursor(**conn_kwargs) as cur:
yield cur
@@ -161,7 +247,7 @@ class SlotsHandler(object):
# Create new logical slots
for database, values in logical_slots.items():
with self._get_local_connection_cursor(database) as cur:
with self.get_local_connection_cursor(dbname=database) as cur:
for name, value in values.items():
try:
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
@@ -174,6 +260,11 @@ class SlotsHandler(object):
slots.pop(name)
self._schedule_load_slots = True
def schedule_advance_slots(self, slots):
if not self._advance:
self._advance = SlotsAdvanceThread(self)
return self._advance.schedule(slots)
def _ensure_logical_slots_replica(self, cluster, slots):
advance_slots = defaultdict(dict) # Group logical slots to be advanced by database name
create_slots = [] # And collect logical slots to be created on the replica
@@ -185,27 +276,18 @@ class SlotsHandler(object):
if name in cluster.slots:
try: # Skip slots that doesn't need to be advanced
if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
advance_slots[value['database']][name] = value
advance_slots[value['database']][name] = int(cluster.slots[name])
except Exception as e:
logger.error('Failed to parse "%s": %r', cluster.slots[name], e)
elif name in cluster.slots: # We want to copy only slots with feedback in a DCS
create_slots.append(name)
# Advance logical slots
for database, values in advance_slots.items():
with self._get_local_connection_cursor(database) as cur:
for name, value in values.items():
try:
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)",
(name, format_lsn(int(cluster.slots[name]))))
except Exception as e:
logger.error("Failed to advance logical replication slot '%s': %r", name, e)
if isinstance(e, UndefinedFile):
create_slots.append(name)
self._schedule_load_slots = True
return create_slots
error, copy_slots = self.schedule_advance_slots(advance_slots)
if error:
self._schedule_load_slots = True
return create_slots + copy_slots
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None):
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None, paused=False):
ret = None
if self._postgresql.major_version >= 90400 and cluster.config:
try:
@@ -214,7 +296,7 @@ class SlotsHandler(object):
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role,
nofailover, self._postgresql.major_version, True)
self._drop_incorrect_slots(cluster, slots)
self._drop_incorrect_slots(cluster, slots, paused)
self._ensure_physical_slots(slots)
@@ -235,7 +317,7 @@ class SlotsHandler(object):
@contextmanager
def _get_leader_connection_cursor(self, leader):
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
conn_kwargs['database'] = self._postgresql.database
conn_kwargs['dbname'] = self._postgresql.database
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
yield cur
@@ -244,35 +326,69 @@ class SlotsHandler(object):
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
try:
with self._get_leader_connection_cursor(cluster.leader) as cur:
cur.execute("SELECT catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = %s", (slot_name,))
if cur.rowcount < 1:
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)",
([n for n, v in self._unready_logical_slots.items() if v is None] + [slot_name],))
slots = {row[0]: row[1] for row in cur}
if slot_name not in slots:
return logger.warning('Physical slot %s does not exist on the primary', slot_name)
catalog_xmin = cur.fetchone()[0]
catalog_xmin = slots.pop(slot_name)
except Exception as e:
return 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
# 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):
value = self._replication_slots.get(name)
if not value or catalog_xmin <= value['catalog_xmin']:
self._unready_logical_slots.remove(name)
# 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
# 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.
if not value or self._unready_logical_slots[name] <= catalog_xmin <= value['catalog_xmin']:
del self._unready_logical_slots[name]
if value:
logger.info('Logical slot %s is safe to be used after a failover', name)
def copy_logical_slots(self, leader, slots):
def copy_logical_slots(self, cluster, create_slots):
leader = cluster.leader
slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version)
with self._get_leader_connection_cursor(leader) as cur:
try:
cur.execute("SELECT slot_name, catalog_xmin, "
cur.execute("SELECT slot_name, slot_type, datname, plugin, catalog_xmin, "
"pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint, "
"pg_catalog.pg_read_binary_file('pg_replslot/' || slot_name || '/state')"
" FROM pg_catalog.pg_get_replication_slots() WHERE NOT pg_catalog.pg_is_in_recovery()"
" AND slot_name = ANY(%s)", (slots,))
slots = {r[0]: {'catalog_xmin': r[1], 'confirmed_flush_lsn': r[2], 'data': r[3]} for r in cur}
" 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,))
create_slots = {}
for r in cur:
if r[0] in slots: # slot_name is defined in the global configuration
slot = {'type': r[1], 'database': r[2], 'plugin': r[3],
'catalog_xmin': r[4], 'confirmed_flush_lsn': r[5], 'data': r[6]}
if compare_slots(slot, slots[r[0]]):
create_slots[r[0]] = slot
else:
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)
except Exception as e:
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
if isinstance(slots, dict) and self._postgresql.stop():
if isinstance(create_slots, dict) and create_slots and self._postgresql.stop():
pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
for name, value in slots.items():
for name, value in create_slots.items():
slot_dir = os.path.join(pg_replslot_dir, name)
slot_tmp_dir = slot_dir + '.tmp'
if os.path.exists(slot_tmp_dir):
@@ -287,7 +403,7 @@ class SlotsHandler(object):
shutil.rmtree(slot_dir)
os.rename(slot_tmp_dir, slot_dir)
fsync_dir(slot_dir)
self._unready_logical_slots.add(name)
self._unready_logical_slots[name] = None
fsync_dir(pg_replslot_dir)
self._postgresql.start()
@@ -297,6 +413,9 @@ class SlotsHandler(object):
self._schedule_load_slots = self._force_readiness_check = value
def on_promote(self):
if self._advance:
self._advance.on_promote()
if self._unready_logical_slots:
logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
self._unready_logical_slots)
set(self._unready_logical_slots))
+24 -11
View File
@@ -99,9 +99,11 @@ class String(namedtuple('String', 'version_from,version_till')):
# key - parameter name
# value - tuple or multiple tuples if something was changing in GUC across postgres versions
parameters = CaseInsensitiveDict({
'allow_in_place_tablespaces': Bool(150000, None),
'allow_system_table_mods': Bool(90300, None),
'application_name': String(90300, None),
'archive_command': String(90300, None),
'archive_library': String(150000, None),
'archive_mode': (
Bool(90300, 90500),
EnumBool(90500, None, ('always',))
@@ -151,14 +153,17 @@ parameters = CaseInsensitiveDict({
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_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, None, ('auto',)),
'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),
@@ -170,7 +175,7 @@ parameters = CaseInsensitiveDict({
'DateStyle': String(90300, None),
'db_user_namespace': Bool(90300, None),
'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'),
'debug_invalidate_system_caches_always': Integer(140000, None, '0', '0', None),
'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),
@@ -201,6 +206,7 @@ parameters = CaseInsensitiveDict({
'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),
@@ -208,7 +214,6 @@ parameters = CaseInsensitiveDict({
'enable_partition_pruning': Bool(110000, None),
'enable_partitionwise_aggregate': Bool(110000, None),
'enable_partitionwise_join': Bool(110000, None),
'enable_resultcache': Bool(140000, None),
'enable_seqscan': Bool(90300, None),
'enable_sort': Bool(90300, None),
'enable_tidscan': Bool(90300, None),
@@ -236,10 +241,10 @@ parameters = CaseInsensitiveDict({
'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'),
'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'),
'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),
@@ -296,6 +301,7 @@ parameters = CaseInsensitiveDict({
'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),
@@ -346,7 +352,7 @@ parameters = CaseInsensitiveDict({
Integer(90400, 90600, 1, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'min_dynamic_shared_memory': Integer(140000, None, '0', '2147483647', 'MB'),
'min_dynamic_shared_memory': Integer(140000, None, 0, 2147483647, 'MB'),
'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
@@ -370,6 +376,8 @@ parameters = CaseInsensitiveDict({
'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),
@@ -399,7 +407,8 @@ parameters = CaseInsensitiveDict({
'ssl_renegotiation_limit': Integer(90300, 90500, 0, 2147483647, 'kB'),
'standard_conforming_strings': Bool(90300, None),
'statement_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'stats_temp_directory': String(90300, None),
'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)
@@ -458,15 +467,19 @@ parameters = CaseInsensitiveDict({
'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_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_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, None),
'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'),
+40
View File
@@ -0,0 +1,40 @@
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError']
_legacy = False
try:
from psycopg2 import __version__
from . import MIN_PSYCOPG2, parse_version
if parse_version(__version__) < MIN_PSYCOPG2:
raise ImportError
from psycopg2 import connect, Error, DatabaseError, OperationalError, ProgrammingError
from psycopg2.extensions import adapt
try:
from psycopg2.extensions import quote_ident as _quote_ident
except ImportError:
_legacy = True
def quote_literal(value, conn=None):
value = adapt(value)
if conn:
value.prepare(conn)
return value.getquoted().decode('utf-8')
except ImportError:
from psycopg import connect as _connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
def connect(*args, **kwargs):
ret = _connect(*args, **kwargs)
ret.server_version = ret.pgconn.server_version # compatibility with psycopg2
return ret
def _quote_ident(value, conn):
return sql.Identifier(value).as_string(conn)
def quote_literal(value, conn=None):
return sql.Literal(value).as_string(conn)
def quote_ident(value, conn=None):
if _legacy or conn is None:
return '"{0}"'.format(value.replace('"', '""'))
return _quote_ident(value, conn)
+3
View File
@@ -34,6 +34,9 @@ class PatroniRequest(object):
if self._apply_ssl_file_param(config, 'cert'):
self._apply_ssl_file_param(config, 'key')
password = self._get_cfg_value(config, 'keyfile_password')
self._apply_pool_param('key_password', password)
else:
self._pool.connection_pool_kw.pop('key_file', None)
+14 -10
View File
@@ -3,10 +3,12 @@
import json
import logging
import sys
import boto.ec2
import boto3
from patroni.utils import Retry, RetryFailedError
from patroni.request import get as requests_get
from ..utils import Retry, RetryFailedError
from ..request import get as requests_get
from botocore.exceptions import ClientError
logger = logging.getLogger(__name__)
@@ -16,7 +18,7 @@ class AWSConnection(object):
def __init__(self, cluster_name):
self.available = False
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=(boto.exception.StandardError,))
self._retry = Retry(deadline=300, max_delay=30, max_tries=-1, retry_exceptions=(ClientError,))
try:
# get the instance id
r = requests_get('http://169.254.169.254/latest/dynamic/instance-identity/document', timeout=2.1)
@@ -42,20 +44,22 @@ class AWSConnection(object):
def _tag_ebs(self, conn, role):
""" set tags, carrying the cluster name, instance role and instance id for the EBS storage """
tags = {'Name': 'spilo_' + self.cluster_name, 'Role': role, 'Instance': self.instance_id}
volumes = conn.get_all_volumes(filters={'attachment.instance-id': self.instance_id})
conn.create_tags([v.id for v in volumes], tags)
tags = [{'Key': 'Name', 'Value': 'spilo_' + self.cluster_name},
{'Key': 'Role', 'Value': role},
{'Key': 'Instance', 'Value': 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)
def _tag_ec2(self, conn, role):
""" tag the current EC2 instance with a cluster role """
tags = {'Role': role}
conn.create_tags([self.instance_id], tags)
tags = [{'Key': 'Role', 'Value': role}]
conn.create_tags(Resources=[self.instance_id], Tags=tags)
def on_role_change(self, new_role):
if not self.available:
return False
try:
conn = self.retry(boto.ec2.connect_to_region, self.region)
conn = boto3.resource('ec2', region_name=self.region)
self.retry(self._tag_ec2, conn, new_role)
self.retry(self._tag_ebs, conn, new_role)
except RetryFailedError:
+4 -3
View File
@@ -27,13 +27,14 @@ import argparse
import csv
import logging
import os
import psycopg2
import subprocess
import sys
import time
from collections import namedtuple
from .. import psycopg
logger = logging.getLogger(__name__)
RETRY_SLEEP_INTERVAL = 1
@@ -215,7 +216,7 @@ class WALERestore(object):
if self.master_connection:
try:
# get the difference in bytes between the current WAL location and the backup start offset
with psycopg2.connect(self.master_connection) as con:
with psycopg.connect(self.master_connection) as con:
if con.server_version >= 100000:
wal_name = 'wal'
lsn_name = 'lsn'
@@ -233,7 +234,7 @@ class WALERestore(object):
(backup_start_lsn, backup_start_lsn, backup_start_lsn))
diff_in_bytes = int(cur.fetchone()[0])
except psycopg2.Error:
except psycopg.Error:
logger.exception('could not determine difference with the master location')
if attempts_no < self.retries: # retry in case of a temporarily connection issue
attempts_no = attempts_no + 1
+1 -1
View File
@@ -362,7 +362,7 @@ def polling_loop(timeout, interval=1):
def split_host_port(value, default_port):
t = value.rsplit(':', 1)
if ':' in t[0]:
t[0] = t[0].strip('[]')
t[0] = ','.join([h.strip().strip('[]') for h in t[0].split(',')])
t.append(default_port)
return t[0], int(t[1])
+10 -2
View File
@@ -37,6 +37,10 @@ def validate_host_port(host_port, listen=False, multiple_hosts=False):
hosts = hosts.split(",")
else:
hosts = [hosts]
if "*" in hosts:
if len(hosts) != 1:
raise ConfigParseError("expecting '*' alone")
hosts = [p[-1][0] for p in socket.getaddrinfo(None, port, 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)]
for host in hosts:
proto = socket.getaddrinfo(host, "", 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
s = socket.socket(proto[0][0], socket.SOCK_STREAM)
@@ -178,9 +182,11 @@ class Schema(object):
self.validator = validator
def __call__(self, data):
errors = []
for i in self.validate(data):
if not i.status:
print(i)
errors.append(str(i))
return errors
def validate(self, data):
self.data = data
@@ -302,10 +308,11 @@ validate_host_port_listen.expected_type = string_types
validate_host_port_listen_multiple_hosts.expected_type = string_types
validate_data_dir.expected_type = string_types
validate_etcd = {
Or("host", "hosts", "srv", "url", "proxy"): Case({
Or("host", "hosts", "srv", "srv_suffix", "url", "proxy"): Case({
"host": validate_host_port,
"hosts": Or(comma_separated_host_port, [validate_host_port]),
"srv": str,
"srv_suffix": str,
"url": str,
"proxy": str})
}
@@ -363,6 +370,7 @@ schema = Schema({
"postgresql": {
"listen": validate_host_port_listen_multiple_hosts,
"connect_address": validate_connect_address,
"proxy_address": validate_connect_address,
"authentication": {
"replication": userattributes,
"superuser": userattributes,
+1 -1
View File
@@ -1 +1 @@
__version__ = '2.1.0'
__version__ = '2.1.5'
+2 -2
View File
@@ -16,10 +16,10 @@ IOC_DIRBITS = 2
# Non-generic platform special cases
machine = platform.machine()
if machine in ['mips', 'sparc', 'powerpc', 'ppc64']: # pragma: no cover
if machine in ['mips', 'sparc', 'powerpc', 'ppc64', 'ppc64le']: # pragma: no cover
IOC_SIZEBITS = 13
IOC_DIRBITS = 3
IOC_NONE, IOC_WRITE, IOC_READ = 1, 2, 4
IOC_NONE, IOC_WRITE, IOC_READ = 1, 4, 2
elif machine == 'parisc': # pragma: no cover
IOC_WRITE, IOC_READ = 2, 1
+4 -2
View File
@@ -91,7 +91,7 @@ bootstrap:
# Some additional users users which needs to be created after initializing new cluster
users:
admin:
password: admin
password: admin%
options:
- createrole
- createdb
@@ -99,6 +99,8 @@ bootstrap:
postgresql:
listen: 127.0.0.1:5432
connect_address: 127.0.0.1:5432
# proxy_address: 127.0.0.1:5433 # The address of connection pool (e.g., pgbouncer) running next to Patroni/Postgres. Only for service discovery.
data_dir: data/postgresql0
# bin_dir:
# config_dir:
@@ -119,7 +121,7 @@ postgresql:
# Fully qualified kerberos ticket file for the running user
# same as KRB5CCNAME used by the GSS
# krb_server_keyfile: /var/spool/keytabs/postgres
unix_socket_directories: '.'
unix_socket_directories: '..' # parent directory of data_dir
# Additional fencing script executed after acquiring the leader lock but before promoting the replica
#pre_promote: /path/to/pre_promote.sh
+3 -2
View File
@@ -85,7 +85,7 @@ bootstrap:
# Some additional users users which needs to be created after initializing new cluster
users:
admin:
password: admin
password: admin%
options:
- createrole
- createdb
@@ -93,6 +93,7 @@ bootstrap:
postgresql:
listen: 127.0.0.1:5433
connect_address: 127.0.0.1:5433
# proxy_address: 127.0.0.1:5434 # The address of connection pool (e.g., pgbouncer) running next to Patroni/Postgres. Only for service discovery.
data_dir: data/postgresql1
# bin_dir:
# config_dir:
@@ -113,7 +114,7 @@ postgresql:
# Fully qualified kerberos ticket file for the running user
# same as KRB5CCNAME used by the GSS
# krb_server_keyfile: /var/spool/keytabs/postgres
unix_socket_directories: '.'
unix_socket_directories: '..' # parent directory of data_dir
basebackup:
- verbose
- max-rate: 100M
+3 -2
View File
@@ -82,7 +82,7 @@ bootstrap:
# Some additional users users which needs to be created after initializing new cluster
users:
admin:
password: admin
password: admin%
options:
- createrole
- createdb
@@ -90,6 +90,7 @@ bootstrap:
postgresql:
listen: 127.0.0.1:5434
connect_address: 127.0.0.1:5434
# proxy_address: 127.0.0.1:5435 # The address of connection pool (e.g., pgbouncer) running next to Patroni/Postgres. Only for service discovery.
data_dir: data/postgresql2
# bin_dir:
# config_dir:
@@ -110,7 +111,7 @@ postgresql:
# Fully qualified kerberos ticket file for the running user
# same as KRB5CCNAME used by the GSS
# krb_server_keyfile: /var/spool/keytabs/postgres
unix_socket_directories: '.'
unix_socket_directories: '..' # parent directory of data_dir
tags:
nofailover: false
noloadbalance: false
+18 -21
View File
@@ -1,31 +1,28 @@
#!/bin/sh
#!/bin/bash
if [ $# -ne 1 ]; then
>&2 echo "usage: $0 <version>"
exit 1
fi
readonly VERSIONFILE="patroni/version.py"
# Release process:
# 1. Open a PR that updates release notes and Patroni version
# 2. Merge it
# 3. Run release.sh
# 4. After the new tag is pushed, the .github/workflows/release.yaml will run tests and upload the new package to test.pypi.org
# 5. 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
set -xe
python3 --version
if python3 --version &> /dev/null; then
alias python=python3
shopt -s expand_aliases
fi
python --version
git --version
version=$1
version=$(python -c 'from patroni.version import __version__; print(__version__)')
sed -i "s/__version__ = .*/__version__ = '${version}'/" "${VERSIONFILE}"
python3 setup.py clean
python3 setup.py test
python3 setup.py flake8
python setup.py clean
python setup.py test
python setup.py flake8
git add "${VERSIONFILE}"
git commit -m "Bumped version to $version"
git push
python3 setup.py sdist bdist_wheel upload
git tag v${version}
git tag "v$version"
git push --tags
+1 -1
View File
@@ -1,7 +1,7 @@
psycopg2-binary
behave
coverage
flake8
flake8>=3.0.0
mock
pytest-cov
pytest
+1 -1
View File
@@ -1,6 +1,6 @@
urllib3>=1.19.1,!=1.21
ipaddress; python_version=="2.7"
boto
boto3
PyYAML
six >= 1.7
kazoo>=1.3.1
+25 -69
View File
@@ -5,6 +5,7 @@
"""
import inspect
import logging
import os
import sys
@@ -17,16 +18,15 @@ MAIN_PACKAGE = NAME
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
LICENSE = 'The MIT License'
URL = 'https://github.com/zalando/patroni'
AUTHOR = 'Alexander Kukushkin, Dmitrii Dolgov, Oleksii Kliukin'
AUTHOR_EMAIL = 'alexander.kukushkin@zalando.de, [email protected], [email protected]'
AUTHOR = 'Alexander Kukushkin, Polina Bungina'
AUTHOR_EMAIL = 'akukushkin@microsoft.com, [email protected]'
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
' zookeeper exhibitor consul streaming replication kubernetes k8s'
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
COVERAGE_XML = True
COVERAGE_HTML = False
# Add here all kinds of additional classifiers as defined under
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
@@ -49,29 +49,29 @@ CLASSIFIERS = [
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: Implementation :: CPython',
]
CONSOLE_SCRIPTS = ['patroni = patroni:main',
CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main',
'patronictl = patroni.ctl:ctl',
'patroni_raft_controller = patroni.raft_controller:main',
"patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main"]
class Flake8(Command):
class _Command(Command):
user_options = []
def initialize_options(self):
from flake8.main import application
self.flake8 = application.Application()
self.flake8.initialize([])
pass
def finalize_options(self):
pass
class Flake8(_Command):
def package_files(self):
seen_package_directories = ()
directories = self.distribution.package_dir or {}
@@ -93,68 +93,29 @@ class Flake8(Command):
return [package for package in self.package_files()] + ['tests', 'setup.py']
def run(self):
self.flake8.run_checks(self.targets())
self.flake8.formatter.start()
self.flake8.report_errors()
self.flake8.report_statistics()
self.flake8.report_benchmarks()
self.flake8.formatter.stop()
try:
self.flake8.exit()
except SystemExit as e:
# Cause system exit only if exit code is not zero (terminates
# other possibly remaining/pending setuptools commands).
if e.code:
raise
from flake8.main.cli import main
logging.getLogger().setLevel(logging.ERROR)
main(self.targets())
class PyTest(Command):
class PyTest(_Command):
user_options = [('cov=', None, 'Run coverage'), ('cov-xml=', None, 'Generate junit xml report'),
('cov-html=', None, 'Generate junit html report')]
def initialize_options(self):
self.cov = []
self.cov_xml = False
self.cov_html = False
def finalize_options(self):
if self.cov_xml or self.cov_html:
self.cov = ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
if self.cov_xml:
self.cov.extend(['--cov-report', 'xml'])
if self.cov_html:
self.cov.extend(['--cov-report', 'html'])
def run_tests(self):
def run(self):
try:
import pytest
except Exception:
raise RuntimeError('py.test is not installed, run: pip install pytest')
import logging
silence = logging.WARNING
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
logging.getLogger().setLevel(logging.WARNING)
args = ['--verbose', 'tests', '--doctest-modules', MAIN_PACKAGE] +\
['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
if self.cov:
args += self.cov
['-s' if logging.getLogger().getEffectiveLevel() < logging.WARNING else '--capture=fd'] +\
['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing', '--cov-report', 'xml']
errno = pytest.main(args=args)
sys.exit(errno)
def run(self):
from pkg_resources import evaluate_marker
requirements = set(self.distribution.install_requires + ['mock>=2.0.0', 'pytest-cov', 'pytest'])
for k, v in self.distribution.extras_require.items():
if not k.startswith(':') or evaluate_marker(k[1:]):
requirements.update(v)
self.distribution.fetch_build_eggs(list(requirements))
self.run_tests()
def read(fname):
with open(os.path.join(__location__, fname)) as fd:
@@ -162,6 +123,8 @@ def read(fname):
def setup_package(version):
logging.basicConfig(format='%(message)s', level=os.getenv('LOGLEVEL', logging.WARNING))
# Assemble additional setup commands
cmdclass = {'test': PyTest, 'flake8': Flake8}
@@ -184,12 +147,6 @@ def setup_package(version):
if not extra:
install_requires.append(r)
command_options = {'test': {}}
if COVERAGE_XML:
command_options['test']['cov_xml'] = 'setup.py', True
if COVERAGE_HTML:
command_options['test']['cov_html'] = 'setup.py', True
setup(
name=NAME,
version=version,
@@ -206,9 +163,7 @@ def setup_package(version):
python_requires='>=2.7',
install_requires=install_requires,
extras_require=EXTRAS_REQUIRE,
setup_requires='flake8',
cmdclass=cmdclass,
command_options=command_options,
entry_points={'console_scripts': CONSOLE_SCRIPTS},
)
@@ -216,13 +171,14 @@ def setup_package(version):
if __name__ == '__main__':
old_modules = sys.modules.copy()
try:
from patroni import check_psycopg2, fatal, __version__
from patroni import check_psycopg, fatal
from patroni.version import __version__
finally:
sys.modules.clear()
sys.modules.update(old_modules)
if sys.version_info < (2, 7, 0):
fatal('Patroni needs to be run with Python 2.7+')
check_psycopg2()
check_psycopg()
setup_package(__version__)
+13 -10
View File
@@ -5,9 +5,10 @@ import unittest
from mock import Mock, patch
import psycopg2
import urllib3
import patroni.psycopg as psycopg
from patroni.dcs import Leader, Member
from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler
@@ -85,15 +86,15 @@ class MockCursor(object):
def execute(self, sql, *params):
if sql.startswith('blabla'):
raise psycopg2.ProgrammingError()
raise psycopg.ProgrammingError()
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
raise psycopg2.OperationalError()
raise psycopg.OperationalError()
elif sql.startswith('RetryFailedError'):
raise RetryFailedError('retry')
elif sql.startswith('SELECT catalog_xmin'):
self.results = [(100, 501)]
elif sql.startswith('SELECT slot_name, catalog_xmin'):
self.results = [('ls', 100, 500, b'123456')]
self.results = [('postgresql0', 100), ('ls', 100)]
elif sql.startswith('SELECT slot_name, slot_type, datname, plugin, catalog_xmin'):
self.results = [('ls', 'logical', 'a', 'b', 100, 500, b'123456')]
elif sql.startswith('SELECT slot_name'):
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b', 5, 100, 500)]
elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'):
@@ -162,7 +163,7 @@ class MockConnect(object):
pass
def psycopg2_connect(*args, **kwargs):
def psycopg_connect(*args, **kwargs):
return MockConnect()
@@ -176,7 +177,7 @@ class PostgresInit(unittest.TestCase):
'force_parallel_mode': '1', 'constraint_exclusion': '',
'max_stack_depth': 'Z', 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200}
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.postgresql.CallbackExecutor', Mock())
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
@@ -187,9 +188,11 @@ class PostgresInit(unittest.TestCase):
self.p = Postgresql({'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
'config_dir': data_dir, 'retry_timeout': 10,
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
'listen': '127.0.0.2, 127.0.0.3:5432', 'connect_address': '127.0.0.2:5432',
'listen': '127.0.0.2, 127.0.0.3:5432',
'connect_address': '127.0.0.2:5432', 'proxy_address': '127.0.0.2:5433',
'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
'replication': {'username': '', 'password': 'rep-pass'}},
'replication': {'username': '', 'password': 'rep-pass'},
'rewind': {'username': 'rewind', 'password': 'test'}},
'remove_data_directory_on_rewind_failure': True,
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla',
'parameters': self._PARAMETERS,
+35 -30
View File
@@ -1,9 +1,10 @@
import datetime
import json
import psycopg2
import unittest
import socket
import patroni.psycopg as psycopg
from mock import Mock, PropertyMock, patch
from patroni.api import RestApiHandler, RestApiServer
from patroni.dcs import ClusterConfig, Member
@@ -11,7 +12,8 @@ from patroni.ha import _MemberStatus
from patroni.utils import tzutc
from six import BytesIO as IO
from six.moves import BaseHTTPServer
from . import psycopg2_connect, MockCursor
from six.moves.socketserver import ThreadingMixIn
from . import psycopg_connect, MockCursor
from .test_ha import get_cluster_initialized_without_leader
@@ -35,7 +37,7 @@ class MockPostgresql(object):
@staticmethod
def connection():
return psycopg2_connect()
return psycopg_connect()
@staticmethod
def postmaster_start_time():
@@ -45,6 +47,10 @@ class MockPostgresql(object):
def replica_cached_timeline(_):
return 2
@staticmethod
def is_running():
return True
class MockWatchdog(object):
is_healthy = False
@@ -77,7 +83,7 @@ class MockHa(object):
@staticmethod
def fetch_nodes_statuses(members):
return [_MemberStatus(None, True, None, 0, None, {}, False)]
return [_MemberStatus(None, True, None, 0, 0, None, {}, False)]
@staticmethod
def schedule_future_restart(data):
@@ -128,6 +134,10 @@ class MockPatroni(object):
def sighup_handler():
pass
@staticmethod
def api_sigterm():
pass
class MockRequest(object):
@@ -179,11 +189,13 @@ class TestRestApiHandler(unittest.TestCase):
MockPatroni.dcs.cluster.is_synchronous_mode = Mock(return_value=True)
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
MockRestApiServer(RestApiHandler, 'GET /synchronous')
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
MockPatroni.dcs.cluster.sync.members = []
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
MockPatroni.dcs.cluster = None
@@ -283,7 +295,12 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_OPTIONS(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
def test_do_GET_liveness(self):
def test_do_HEAD(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'HEAD / HTTP/1.0'))
@patch.object(MockPatroni, 'dcs')
def test_do_GET_liveness(self, mock_dcs):
mock_dcs.ttl.return_value = PropertyMock(30)
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /liveness HTTP/1.0'))
def test_do_GET_readiness(self):
@@ -358,6 +375,11 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_POST_reload(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /reload HTTP/1.0' + self._authorization))
@patch('os.environ', {'BEHAVE_DEBUG': 'true'})
@patch('os.name', 'nt')
def test_do_POST_sigterm(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /sigterm HTTP/1.0' + self._authorization))
@patch.object(MockPatroni, 'dcs')
def test_do_POST_restart(self, mock_dcs):
mock_dcs.get_cluster.return_value.is_paused.return_value = False
@@ -435,9 +457,9 @@ class TestRestApiHandler(unittest.TestCase):
@patch('time.sleep', Mock())
def test_RestApiServer_query(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg.OperationalError)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
@patch('time.sleep', Mock())
@@ -548,7 +570,8 @@ class TestRestApiServer(unittest.TestCase):
self.assertRaises(ValueError, MockRestApiServer, None, '', bad_config)
self.assertRaises(ValueError, self.srv.reload_config, bad_config)
self.assertRaises(ValueError, self.srv.reload_config, {})
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)):
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)), \
patch.object(MockRestApiServer, 'server_close', Mock()):
self.srv.reload_config({'listen': ':8008'})
@patch.object(MockPatroni, 'dcs')
@@ -573,32 +596,14 @@ class TestRestApiServer(unittest.TestCase):
def test_socket_error(self):
self.assertRaises(socket.error, MockRestApiServer, Mock(), '', {'listen': '*:8008'})
@patch.object(MockRestApiServer, 'finish_request', Mock())
@patch.object(ThreadingMixIn, 'process_request_thread', Mock())
def test_process_request_thread(self):
mock_socket = Mock()
self.srv.process_request_thread((mock_socket, 1), '2')
mock_socket.context.wrap_socket.side_effect = socket.error
self.srv.process_request_thread((mock_socket, 1), '2')
@patch.object(socket.socket, 'accept')
def test_get_request(self, mock_accept):
newsock = Mock()
mock_accept.return_value = (newsock, '2')
self.srv.socket = Mock()
self.assertEqual(self.srv.get_request(), ((self.srv.socket, newsock), '2'))
self.srv.process_request_thread(Mock(), '2')
@patch.object(MockRestApiServer, 'process_request', Mock(side_effect=RuntimeError))
@patch.object(MockRestApiServer, 'get_request', Mock(return_value=(Mock(), ('127.0.0.1', 55555))))
def test_process_request_error(self):
mock_address = ('127.0.0.1', 55555)
mock_socket = Mock()
mock_ssl_socket = (Mock(), Mock())
for mock_request in (mock_socket, mock_ssl_socket):
with patch.object(
MockRestApiServer,
'get_request',
Mock(return_value=(mock_request, mock_address))
):
self.srv._handle_request_noblock()
self.srv._handle_request_noblock()
@patch('ssl._ssl._test_decode_cert', Mock())
def test_reload_local_certificate(self):
+15 -10
View File
@@ -1,4 +1,4 @@
import boto.ec2
import botocore
import sys
import unittest
import urllib3
@@ -8,21 +8,27 @@ from collections import namedtuple
from patroni.scripts.aws import AWSConnection, main as _main
class MockEc2Connection(object):
class MockVolumes(object):
@staticmethod
def get_all_volumes(*args, **kwargs):
def filter(*args, **kwargs):
oid = namedtuple('Volume', 'id')
return [oid(id='a'), oid(id='b')]
class MockEc2Connection(object):
volumes = MockVolumes()
@staticmethod
def create_tags(objects, *args, **kwargs):
if len(objects) == 0:
raise boto.exception.BotoServerError(503, 'Service Unavailable', 'Request limit exceeded')
def create_tags(Resources, **kwargs):
if len(Resources) == 0:
raise botocore.exceptions.ClientError({'Error': {'Code': 503, 'Message': 'Request limit exceeded'}},
'create_tags')
return True
@patch('boto.ec2.connect_to_region', Mock(return_value=MockEc2Connection()))
@patch('boto3.resource', Mock(return_value=MockEc2Connection()))
class TestAWSConnection(unittest.TestCase):
@patch('patroni.scripts.aws.requests_get', Mock(return_value=urllib3.HTTPResponse(
@@ -32,7 +38,7 @@ class TestAWSConnection(unittest.TestCase):
def test_on_role_change(self):
self.assertTrue(self.conn.on_role_change('master'))
with patch.object(MockEc2Connection, 'get_all_volumes', Mock(return_value=[])):
with patch.object(MockVolumes, 'filter', Mock(return_value=[])):
self.conn._retry.max_tries = 1
self.assertFalse(self.conn.on_role_change('master'))
@@ -46,8 +52,7 @@ class TestAWSConnection(unittest.TestCase):
conn = AWSConnection('test')
self.assertFalse(conn.aws_available())
@patch('patroni.scripts.aws.requests_get', Mock(return_value=urllib3.HTTPResponse(
status=200, body=b'{"instanceId": "012345", "region": "eu-west-1"}')))
@patch('patroni.scripts.aws.requests_get', Mock(return_value=urllib3.HTTPResponse(status=503, body=b'Error')))
@patch('sys.exit', Mock())
def test_main(self):
self.assertIsNone(_main())
+3 -2
View File
@@ -8,11 +8,11 @@ from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.config import ConfigHandler
from . import psycopg2_connect, BaseTestPostgresql
from . import psycopg_connect, BaseTestPostgresql
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('os.rename', Mock())
class TestBootstrap(BaseTestPostgresql):
@@ -164,6 +164,7 @@ class TestBootstrap(BaseTestPostgresql):
@patch('os.unlink', Mock())
@patch('shutil.copy', Mock())
@patch('os.path.isfile', Mock(return_value=True))
@patch('patroni.postgresql.bootstrap.quote_ident', Mock())
@patch.object(Bootstrap, 'call_post_bootstrap', Mock(return_value=True))
@patch.object(Bootstrap, '_custom_bootstrap', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
+2 -2
View File
@@ -27,8 +27,8 @@ class TestCancellableSubprocess(unittest.TestCase):
def test_cancel(self):
self.c._process = Mock()
self.c._process.is_running.return_value = True
self.c._process.children.side_effect = psutil.Error()
self.c._process.suspend.side_effect = psutil.Error()
self.c._process.children.side_effect = psutil.NoSuchProcess(123)
self.c._process.suspend.side_effect = psutil.AccessDenied()
self.c.cancel()
self.c._process.is_running.side_effect = [True, False]
self.c.cancel()
+1
View File
@@ -40,6 +40,7 @@ class TestConfig(unittest.TestCase):
'PATRONI_RESTAPI_ALLOWLIST_INCLUDE_MEMBERS': 'on',
'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432',
'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432',
'PATRONI_POSTGRESQL_PROXY_ADDRESS': '127.0.0.1:5433',
'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0',
'PATRONI_POSTGRESQL_CONFIG_DIR': 'data/postgres0',
'PATRONI_POSTGRESQL_PGPASS': '/tmp/pgpass0',
+56 -5
View File
@@ -2,7 +2,7 @@ import consul
import unittest
from consul import ConsulException, NotFound
from mock import Mock, patch
from mock import Mock, PropertyMock, patch
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession
from . import SleepException
@@ -91,7 +91,7 @@ class TestConsul(unittest.TestCase):
Consul({'ttl': 30, 'scope': 't_', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
'verify': 'on', 'cert': 'bar', 'cacert': 'buz', 'register_service': True})
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10,
'register_service': True})
'register_service': True, 'service_check_tls_server_name': True})
self.c._base_path = '/service/good'
self.c.get_cluster()
@@ -130,8 +130,9 @@ class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException, InvalidSession]))
def test_touch_member(self):
self.c.refresh_session = Mock(return_value=False)
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
'api_url': 'http://127.0.0.1:8009/patroni'})
with patch.object(Consul, 'update_service', Mock(side_effect=Exception)):
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
'api_url': 'http://127.0.0.1:8009/patroni'})
self.c._register_service = True
self.c.refresh_session = Mock(return_value=True)
for _ in range(0, 4):
@@ -153,11 +154,14 @@ class TestConsul(unittest.TestCase):
def test_set_config_value(self):
self.c.set_config_value('')
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_write_leader_optime(self):
self.c.get_cluster()
self.c.write_leader_optime('1')
@patch.object(consul.Consul.Session, 'renew', Mock())
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_update_leader(self):
self.c.update_leader(12345)
@@ -214,5 +218,52 @@ class TestConsul(unittest.TestCase):
d['role'] = 'bla'
self.assertIsNone(self.c.update_service({}, d))
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_reload_config(self):
self.c.reload_config({'consul': {'token': 'foo'}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
self.assertEqual([], self.c._service_tags)
self.c.reload_config({'consul': {'token': 'foo', 'register_service': True, 'service_tags': ['foo']},
'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
self.assertEqual(["foo"], self.c._service_tags)
self.c.refresh_session = Mock(return_value=False)
d = {'role': 'replica', 'api_url': 'http://a/t', 'conn_url': 'pg://c:1', 'state': 'running'}
# Changing register_service from True to False calls deregister()
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
self.c.touch_member(d)
mock_deregister.assert_called_once()
self.assertEqual([], self.c._service_tags)
# register_service staying False between reloads does not call deregister()
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
self.c.touch_member(d)
self.assertFalse(mock_deregister.called)
# Changing register_service from False to True calls register()
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
with patch('consul.Consul.Agent.Service.register') as mock_register:
self.c.touch_member(d)
mock_register.assert_called_once()
# register_service staying True between reloads does not call register()
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
with patch('consul.Consul.Agent.Service.register') as mock_register:
self.c.touch_member(d)
self.assertFalse(mock_deregister.called)
# register_service staying True between reloads does calls register() if other service data has changed
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
with patch('consul.Consul.Agent.Service.register') as mock_register:
self.c.touch_member(d)
mock_register.assert_called_once()
# register_service staying True between reloads does calls register() if service_tags have changed
self.c.reload_config({'consul': {'register_service': True, 'service_tags': ['foo']}, 'loop_wait': 10,
'ttl': 30, 'retry_timeout': 10})
with patch('consul.Consul.Agent.Service.register') as mock_register:
self.c.touch_member(d)
mock_register.assert_called_once()
+33 -9
View File
@@ -7,13 +7,14 @@ from datetime import datetime, timedelta
from mock import patch, Mock
from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs, parse_dcs, \
get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Failover
from patroni.psycopg import OperationalError
from patroni.utils import tzutc
from psycopg2 import OperationalError
from prettytable import PrettyTable, ALL
from urllib3 import PoolManager
from . import MockConnect, MockCursor, MockResponse, psycopg2_connect
from . import MockConnect, MockCursor, MockResponse, psycopg_connect
from .test_etcd import etcd_read, socket_getaddrinfo
from .test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member
@@ -48,7 +49,7 @@ class TestCtl(unittest.TestCase):
self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None)
self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None)
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
def test_get_cursor(self):
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), {}, role='master'))
@@ -57,7 +58,7 @@ class TestCtl(unittest.TestCase):
# MockCursor returns pg_is_in_recovery as false
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='replica'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'database': 'foo'}, role='any'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'dbname': 'foo'}, role='any'))
def test_parse_dcs(self):
assert parse_dcs(None) is None
@@ -165,7 +166,7 @@ class TestCtl(unittest.TestCase):
def test_get_dcs(self):
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
@patch('patroni.ctl.get_dcs')
@patch.object(etcd.Client, 'read', etcd_read)
@@ -562,7 +563,8 @@ class TestCtl(unittest.TestCase):
@patch('sys.stdout.isatty', return_value=False)
@patch('patroni.ctl.markup_to_pager')
def test_show_diff(self, mock_markup_to_pager, mock_isatty):
@patch('patroni.ctl.find_executable', return_value=None)
def test_show_diff(self, mock_find_executable, mock_markup_to_pager, mock_isatty):
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
mock_markup_to_pager.assert_not_called()
@@ -570,10 +572,10 @@ class TestCtl(unittest.TestCase):
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
mock_markup_to_pager.assert_called_once()
with patch('patroni.ctl.find_executable', Mock(return_value=None)):
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
# Test that unicode handling doesn't fail with an exception
mock_find_executable.return_value = '/usr/bin/less'
show_diff(b"foo:\n bar: \xc3\xb6\xc3\xb6\n".decode('utf-8'),
b"foo:\n bar: \xc3\xbc\xc3\xbc\n".decode('utf-8'))
@@ -591,6 +593,7 @@ class TestCtl(unittest.TestCase):
self.runner.invoke(ctl, ['show-config', 'dummy'])
@patch('patroni.ctl.get_dcs')
@patch('subprocess.call', Mock(return_value=0))
def test_edit_config(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
@@ -646,3 +649,24 @@ class TestCtl(unittest.TestCase):
result = self.runner.invoke(ctl, ['reinit', 'alpha', 'other', '--wait'], input='y\ny')
self.assertIn("Waiting for reinitialize to complete on: other", result.output)
self.assertIn("Reinitialize is completed on: other", result.output)
class TestPatronictlPrettyTable(unittest.TestCase):
def setUp(self):
self.pt = PatronictlPrettyTable(' header', ['foo', 'bar'], hrules=ALL)
def test__get_hline(self):
expected = '+-----+-----+'
self.pt._hrule = expected
self.assertEqual(self.pt._hrule, '+ header----+')
self.assertFalse(self.pt._is_first_hline())
self.assertEqual(self.pt._hrule, expected)
@patch.object(PrettyTable, '_stringify_hrule', Mock(return_value='+-----+-----+'))
def test__stringify_hrule(self):
self.assertEqual(self.pt._stringify_hrule((), 'top_'), '+ header----+')
self.assertFalse(self.pt._is_first_hline())
def test_output(self):
self.assertEqual(str(self.pt), '+ header----+\n| foo | bar |\n+-----+-----+')
+9 -2
View File
@@ -4,7 +4,7 @@ import socket
import unittest
from dns.exception import DNSException
from mock import Mock, patch
from mock import Mock, PropertyMock, patch
from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
from patroni.exceptions import DCSError
from patroni.utils import Retry
@@ -87,7 +87,8 @@ def dns_query(name, _):
raise DNSException()
srv = Mock()
srv.port = 2380
srv.target.to_text.return_value = 'localhost' if name == '_etcd-server._tcp.foobar' else '127.0.0.1'
srv.target.to_text.return_value = \
'localhost' if name in ['_etcd-server._tcp.foobar', '_etcd-server-baz._tcp.foobar'] else '127.0.0.1'
return [srv]
@@ -183,6 +184,7 @@ class TestClient(unittest.TestCase):
def test__get_machines_cache_from_srv(self):
self.client._get_machines_cache_from_srv('foobar')
self.client._get_machines_cache_from_srv('foobar', 'baz')
self.client.get_srv_record = Mock(return_value=[('localhost', 2380)])
self.client._get_machines_cache_from_srv('blabla')
@@ -275,7 +277,9 @@ class TestEtcd(unittest.TestCase):
self.etcd._base_path = '/service/failed'
self.assertFalse(self.etcd.attempt_to_acquire_leader())
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
def test_write_leader_optime(self):
self.etcd.get_cluster()
self.etcd.write_leader_optime('0')
def test_update_leader(self):
@@ -319,3 +323,6 @@ class TestEtcd(unittest.TestCase):
def test_set_history_value(self):
self.assertFalse(self.etcd.set_history_value('{}'))
def test_last_seen(self):
self.assertIsNotNone(self.etcd.last_seen)
+145 -44
View File
@@ -19,7 +19,7 @@ from patroni.utils import tzutc
from patroni.watchdog import Watchdog
from six.moves import builtins
from . import PostgresInit, MockPostmaster, psycopg2_connect, requests_get
from . import PostgresInit, MockPostmaster, psycopg_connect, requests_get
from .test_etcd import socket_getaddrinfo, etcd_read, etcd_write
SYSID = '12345678901'
@@ -35,8 +35,8 @@ def false(*args, **kwargs):
def get_cluster(initialize, leader, members, failover, sync, cluster_config=None):
t = datetime.datetime.now().isoformat()
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '"]]',
[(1, 67197376, 'no recovery target specified', t)])
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '","foo"]]',
[(1, 67197376, 'no recovery target specified', t, 'foo')])
cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1)
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history, None)
@@ -45,6 +45,10 @@ def get_cluster_not_initialized_without_leader(cluster_config=None):
return get_cluster(None, None, [], None, SyncState(None, None, None), cluster_config)
def get_cluster_bootstrapping_without_leader(cluster_config=None):
return get_cluster("", None, [], None, SyncState(None, None, None), cluster_config)
def get_cluster_initialized_without_leader(leader=False, failover=None, sync=None, cluster_config=None):
m1 = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location': 4})
@@ -80,13 +84,14 @@ def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
)
def get_node_status(reachable=True, in_recovery=True, timeline=2,
wal_position=10, nofailover=False, watchdog_failed=False):
def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0,
timeline=2, wal_position=10, nofailover=False,
watchdog_failed=False):
def fetch_node_status(e):
tags = {}
if nofailover:
tags['nofailover'] = True
return _MemberStatus(e, reachable, in_recovery, timeline, wal_position, tags, watchdog_failed)
return _MemberStatus(e, reachable, in_recovery, dcs_last_seen, timeline, wal_position, tags, watchdog_failed)
return fetch_node_status
@@ -283,6 +288,18 @@ class TestHa(PostgresInit):
self.ha.patroni.config.set_dynamic_configuration({'maximum_lag_on_failover': 10})
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
def test_crash_recovery_before_rewind(self):
self.p.is_leader = false
self.p.is_running = false
self.p.controldata = lambda: {'Database cluster state': 'in archive recovery',
'Database system identifier': SYSID}
self.ha._rewind.trigger_check_diverged_lsn()
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEqual(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
def test_recover_with_rewind(self):
@@ -301,6 +318,7 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.run_cycle(), 'fake')
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
@patch.object(Rewind, 'should_remove_data_directory_on_diverged_timelines', PropertyMock(return_value=True))
@patch.object(Bootstrap, 'create_replica', Mock(return_value=1))
def test_recover_with_reinitialize(self):
self.p.is_running = false
@@ -322,7 +340,7 @@ class TestHa(PostgresInit):
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
def test_acquire_lock_as_master(self):
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
@@ -349,7 +367,7 @@ class TestHa(PostgresInit):
self.ha.has_lock = true
self.p.is_leader = false
self.p.set_role('master')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
def test_demote_after_failing_to_obtain_lock(self):
self.ha.acquire_lock = false
@@ -389,8 +407,9 @@ class TestHa(PostgresInit):
self.ha.cluster = get_cluster_initialized_with_leader()
self.ha.cluster.is_unlocked = false
self.ha.has_lock = true
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
@patch.object(Postgresql, '_wait_for_connection_close', Mock())
def test_demote_because_not_having_lock(self):
self.ha.cluster.is_unlocked = false
with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)):
@@ -401,6 +420,8 @@ class TestHa(PostgresInit):
self.ha.has_lock = true
self.ha.update_lock = false
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
with patch.object(Ha, '_get_node_to_follow', Mock(side_effect=DCSError('foo'))):
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
@@ -408,16 +429,16 @@ class TestHa(PostgresInit):
def test_follow(self):
self.ha.cluster.is_unlocked = false
self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
self.ha.patroni.replicatefrom = "foo"
self.p.config.check_recovery_conf = Mock(return_value=(True, False))
self.ha.cluster.config.data.update({'slots': {'l': {'database': 'a', 'plugin': 'b'}}})
self.ha.cluster.members[1].data['tags']['replicatefrom'] = 'postgresql0'
self.ha.patroni.nofailover = True
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
del self.ha.cluster.config.data['slots']
self.ha.cluster.config.data.update({'postgresql': {'use_slots': False}})
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
del self.ha.cluster.config.data['postgresql']['use_slots']
def test_follow_in_pause(self):
@@ -453,10 +474,17 @@ class TestHa(PostgresInit):
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
self.assertEqual(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
def test_bootstrap_not_running_concurrently(self):
self.ha.cluster = get_cluster_bootstrapping_without_leader()
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
self.assertEqual(self.ha.bootstrap(), 'waiting for leader to bootstrap')
def test_bootstrap_initialize_lock_failed(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Postgresql, 'connection', Mock(return_value=None))
def test_bootstrap_initialized_new_cluster(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.e.initialize = true
@@ -474,6 +502,8 @@ class TestHa(PostgresInit):
self.p.is_running = false
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Postgresql, 'connection', Mock(return_value=None))
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.e.initialize = true
@@ -484,7 +514,7 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
def test_reinitialize(self):
self.assertIsNotNone(self.ha.reinitialize())
@@ -536,27 +566,27 @@ class TestHa(PostgresInit):
self.ha.fetch_node_status = get_node_status()
self.ha.has_lock = true
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
f = Failover(0, self.p.name, '', None)
self.ha.cluster = get_cluster_initialized_with_leader(f)
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
self.ha._rewind.rewind_or_reinitialize_needed_and_possible = true
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
self.ha.fetch_node_status = get_node_status(nofailover=True)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.fetch_node_status = get_node_status(watchdog_failed=True)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.fetch_node_status = get_node_status(timeline=1)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.fetch_node_status = get_node_status(wal_position=1)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
# Failover scheduled time must include timezone
scheduled = datetime.datetime.now()
@@ -565,28 +595,28 @@ class TestHa(PostgresInit):
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=30)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=-600)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
scheduled = None
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
def test_manual_failover_from_leader_in_pause(self):
self.ha.has_lock = true
self.ha.is_paused = true
scheduled = datetime.datetime.now()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('PAUSE: no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
self.assertEqual('PAUSE: no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
def test_manual_failover_from_leader_in_synchronous_mode(self):
self.p.is_leader = true
@@ -595,7 +625,7 @@ class TestHa(PostgresInit):
self.ha.is_failover_possible = false
self.ha.process_sync_replication = Mock()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a'))
self.ha.is_failover_possible = true
self.assertEqual('manual failover: demoting myself', self.ha.run_cycle())
@@ -624,6 +654,59 @@ class TestHa(PostgresInit):
self.ha.patroni.nofailover = True
self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
def test_manual_failover_process_no_leader_in_synchronous_mode(self):
self.ha.is_synchronous_mode = true
self.p.is_leader = false
# switchover to a specific node, which name doesn't match our name (postgresql0)
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'other', None))
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
# switchover to our node (postgresql0), which name is not in sync nodes list
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'postgresql0', None),
sync=('leader1', 'blabla'))
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
# switchover from a specific leader, but our name (postgresql0) is not in the sync nodes list
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None),
sync=('leader', 'blabla'))
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
# switchover from a specific leader, but the only sync node (us, postgresql0) has nofailover tag
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None),
sync=('postgresql0'))
self.ha.patroni.nofailover = True
self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
self.ha.patroni.nofailover = False
# manual failover when our name (postgresql0) isn't in the /sync key and the `other` node is not available
self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None),
sync=('leader1', 'blabla'))
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
# manual failover when the `other` node isn't available but our name is in the /sync key
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None),
sync=('leader1', 'postgresql0'))
self.p.pick_synchronous_standby = Mock(return_value=([], []))
self.ha.dcs.write_sync_state = true
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
# manual failover to our node (postgresql0),
# which name is not in sync nodes list (the leader and all sync nodes are not available)
self.p.set_role('replica')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None),
sync=('leader1', 'other'))
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
# manual failover to our node (postgresql0),
# which name is not in sync nodes list (some sync nodes are available)
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None),
sync=('leader1', 'other'))
self.p.set_role('replica')
self.p.pick_synchronous_standby = Mock(return_value=(['leader1'], ['leader1']))
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
def test_manual_failover_process_no_leader_in_pause(self):
self.ha.is_paused = true
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
@@ -756,7 +839,7 @@ class TestHa(PostgresInit):
self.p.config.check_recovery_conf = Mock(return_value=(False, False))
self.ha._leader_timeline = 1
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (leader) the standby leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (leader), the standby leader with the lock')
self.p.set_role('replica')
self.p.config.check_recovery_conf = Mock(return_value=(True, False))
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
@@ -766,7 +849,7 @@ class TestHa(PostgresInit):
self.p.name = 'replica'
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
self.assertEqual(self.ha.run_cycle(),
'no action. I am a secondary (replica) and following a standby leader (leader)')
'no action. I am (replica), a secondary, and following a standby leader (leader)')
with patch.object(Leader, 'conn_url', PropertyMock(return_value='')):
self.assertEqual(self.ha.run_cycle(), 'continue following the old known standby leader')
@@ -860,7 +943,7 @@ class TestHa(PostgresInit):
self.ha.has_lock = false
self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(),
'no action. I am a secondary (postgresql0) and following a leader (leader)')
'no action. I am (postgresql0), a secondary, and following a leader (leader)')
check_calls([(update_lock, False), (demote, False)])
def test_manual_failover_while_starting(self):
@@ -1085,7 +1168,7 @@ class TestHa(PostgresInit):
self.ha.cluster.config.data.clear()
self.ha.has_lock = true
self.ha.cluster.is_unlocked = false
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
def test_watch(self):
self.ha.cluster = get_cluster_initialized_with_leader()
@@ -1097,20 +1180,30 @@ class TestHa(PostgresInit):
def test_shutdown(self):
self.p.is_running = false
self.ha.is_leader = true
def stop(*args, **kwargs):
kwargs['on_shutdown'](123)
self.p.stop = stop
self.ha.shutdown()
self.ha.is_failover_possible = true
self.ha.shutdown()
@patch('time.sleep', Mock())
def test_leader_with_empty_directory(self):
def test_leader_with_not_accessible_data_directory(self):
self.ha.cluster = get_cluster_initialized_with_leader()
self.ha.has_lock = true
self.p.data_directory_empty = true
self.assertEqual(self.ha.run_cycle(), 'released leader key voluntarily as data dir empty and currently leader')
self.p.data_directory_empty = Mock(side_effect=OSError(5, "Input/output error: '{}'".format(self.p.data_dir)))
self.assertEqual(self.ha.run_cycle(),
'released leader key voluntarily as data dir not accessible and currently leader')
self.assertEqual(self.p.role, 'uninitialized')
# as has_lock is mocked out, we need to fake the leader key release
self.ha.has_lock = false
# will not say bootstrap from leader as replica can't self elect
self.assertEqual(self.ha.run_cycle(), "trying to bootstrap from replica 'other'")
# will not say bootstrap because data directory is not accessible
self.assertEqual(self.ha.run_cycle(),
"data directory is not accessible: [Errno 5] Input/output error: '{}'".format(self.p.data_dir))
@patch('patroni.postgresql.mtime', Mock(return_value=1588316884))
@patch.object(builtins, 'open', mock_open(read_data=('1\t0/40159C0\tno recovery target specified\n\n'
@@ -1120,7 +1213,7 @@ class TestHa(PostgresInit):
self.ha.cluster.is_unlocked = false
for tl in (1, 3):
self.p.get_master_timeline = Mock(return_value=tl)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
@patch('sys.exit', return_value=1)
def test_abort_join(self, exit_mock):
@@ -1133,11 +1226,11 @@ class TestHa(PostgresInit):
self.ha.has_lock = true
self.ha.cluster.is_unlocked = false
self.ha.is_paused = true
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0), the leader with the lock')
self.ha.is_paused = false
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
def test_permanent_logical_slots_after_promote(self):
config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1)
self.p.name = 'other'
@@ -1145,7 +1238,7 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
self.ha.cluster = get_cluster_initialized_without_leader(leader=True, cluster_config=config)
self.ha.has_lock = true
self.assertEqual(self.ha.run_cycle(), 'no action. I am (other) the leader with the lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (other), the leader with the lock')
@patch.object(Cluster, 'has_member', true)
def test_run_cycle(self):
@@ -1169,7 +1262,7 @@ class TestHa(PostgresInit):
self.ha.has_lock = true
self.assertEqual(self.ha.run_cycle(), 'PAUSE: released leader key voluntarily due to the system ID mismatch')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('os.path.exists', Mock(return_value=True))
@patch('shutil.rmtree', Mock())
@patch('os.makedirs', Mock())
@@ -1179,8 +1272,16 @@ class TestHa(PostgresInit):
@patch('os.rename', Mock())
@patch('patroni.postgresql.Postgresql.is_starting', Mock(return_value=False))
@patch.object(builtins, 'open', mock_open())
@patch.object(SlotsHandler, 'sync_replication_slots', Mock(return_value=['foo']))
@patch.object(ConfigHandler, 'check_recovery_conf', Mock(return_value=(False, False)))
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000))
@patch.object(SlotsHandler, 'sync_replication_slots', Mock(return_value=['ls']))
def test_follow_copy(self):
self.ha.cluster.is_unlocked = false
self.ha.cluster.config.data['slots'] = {'ls': {'database': 'a', 'plugin': 'b'}}
self.p.is_leader = false
self.assertTrue(self.ha.run_cycle().startswith('Copying logical slots'))
def test_is_failover_possible(self):
self.ha.fetch_node_status = Mock(return_value=_MemberStatus(self.ha.cluster.members[0],
True, True, 0, 2, None, {}, False))
self.assertFalse(self.ha.is_failover_possible(self.ha.cluster.members))
+65 -4
View File
@@ -1,9 +1,10 @@
import datetime
import json
import socket
import time
import unittest
from mock import Mock, mock_open, patch
from mock import Mock, PropertyMock, mock_open, patch
from patroni.dcs.kubernetes import k8s_client, k8s_config, K8sConfig, K8sConnectionFailed,\
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException,\
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
@@ -79,6 +80,28 @@ class TestK8sConfig(unittest.TestCase):
self.assertRaises(k8s_config.ConfigException, k8s_config.load_incluster_config)
k8s_config.load_incluster_config()
self.assertEqual(k8s_config.server, 'https://a:1')
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
def test_refresh_token(self):
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}),\
patch('os.path.isfile', Mock(side_effect=[True, True, False, True, True, True])),\
patch.object(builtins, 'open', Mock(side_effect=[
mock_open(read_data='cert')(), mock_open(read_data='a')(),
mock_open()(), mock_open(read_data='b')(), mock_open(read_data='c')()])):
k8s_config.load_incluster_config(token_refresh_interval=datetime.timedelta(milliseconds=100))
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
time.sleep(0.1)
# token file doesn't exist
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
# token file is empty
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
# token refreshed
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer b')
time.sleep(0.1)
# token refreshed
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer c')
# no need to refresh token
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer c')
def test_load_kube_config(self):
config = {
@@ -212,7 +235,9 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
self.k.manual_failover('foo', 'bar')
def test_set_config_value(self):
self.k.set_config_value('{}')
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map',
Mock(side_effect=k8s_client.rest.ApiException(409, '')), create=True):
self.k.set_config_value('{}', 1)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', create=True)
def test_touch_member(self, mock_patch_namespaced_pod):
@@ -290,11 +315,32 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', mock_namespaced_kind, create=True)
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', mock_namespaced_kind, create=True)
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_service',
Mock(side_effect=[True, False, k8s_client.rest.ApiException(500, '')]), create=True)
def test__create_config_service(self):
Mock(side_effect=[True,
False,
k8s_client.rest.ApiException(409, ''),
k8s_client.rest.ApiException(403, ''),
k8s_client.rest.ApiException(500, ''),
Exception("Unexpected")
]), create=True)
@patch('patroni.dcs.kubernetes.logger.exception')
def test__create_config_service(self, mock_logger_exception):
self.assertIsNotNone(self.k.patch_or_create_config({'foo': 'bar'}))
self.assertIsNotNone(self.k.patch_or_create_config({'foo': 'bar'}))
self.k.patch_or_create_config({'foo': 'bar'})
mock_logger_exception.assert_not_called()
self.k.patch_or_create_config({'foo': 'bar'})
mock_logger_exception.assert_not_called()
self.k.patch_or_create_config({'foo': 'bar'})
mock_logger_exception.assert_called_once()
self.assertEqual(('create_config_service failed',), mock_logger_exception.call_args[0])
mock_logger_exception.reset_mock()
self.k.touch_member({'state': 'running', 'role': 'replica'})
mock_logger_exception.assert_called_once()
self.assertEqual(('create_config_service failed',), mock_logger_exception.call_args[0])
class TestCacheBuilder(BaseTestKubernetes):
@@ -322,3 +368,18 @@ class TestCacheBuilder(BaseTestKubernetes):
def test__list(self):
self.k._pods._func = Mock(side_effect=Exception)
self.assertRaises(Exception, self.k._pods._list)
@patch('patroni.dcs.kubernetes.ObjectCache._watch', Mock(return_value=None))
def test__do_watch(self):
self.assertRaises(AttributeError, self.k._kinds._do_watch, '1')
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
@patch('patroni.dcs.kubernetes.ObjectCache._watch')
def test_kill_stream(self, mock_watch):
self.k._kinds.kill_stream()
mock_watch.return_value.read_chunked.return_value = []
mock_watch.return_value.connection.sock.close.side_effect = Exception
self.k._kinds._do_watch('1')
self.k._kinds.kill_stream()
type(mock_watch.return_value).connection = PropertyMock(side_effect=Exception)
self.k._kinds.kill_stream()
+21 -8
View File
@@ -13,15 +13,24 @@ from patroni.dcs.etcd import AbstractEtcdClientWithFailover
from patroni.exceptions import DCSError
from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler
from patroni import Patroni, main as _main, patroni_main, check_psycopg2
from patroni import check_psycopg
from patroni.__main__ import Patroni, main as _main, patroni_main
from six.moves import BaseHTTPServer, builtins
from threading import Thread
from . import psycopg2_connect, SleepException
from . import psycopg_connect, SleepException
from .test_etcd import etcd_read, etcd_write
from .test_postgresql import MockPostmaster
def mock_import(*args, **kwargs):
if args[0] == 'psycopg':
raise ImportError
ret = Mock()
ret.__version__ = '2.5.3.dev1 a b c'
return ret
class MockFrozenImporter(object):
toc = set(['patroni.dcs.etcd'])
@@ -29,7 +38,7 @@ class MockFrozenImporter(object):
@patch('time.sleep', Mock())
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(ConfigHandler, 'append_pg_hba', Mock())
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
@patch.object(ConfigHandler, 'write_recovery_conf', Mock())
@@ -41,12 +50,16 @@ class MockFrozenImporter(object):
@patch.object(etcd.Client, 'read', etcd_read)
class TestPatroni(unittest.TestCase):
@patch('sys.argv', ['patroni.py'])
def test_no_config(self):
self.assertRaises(SystemExit, patroni_main)
@patch('sys.argv', ['patroni.py', '--validate-config', 'postgres0.yml'])
@patch('socket.socket.connect_ex', Mock(return_value=1))
def test_validate_config(self):
self.assertRaises(SystemExit, patroni_main)
with patch.object(config.Config, '__init__', Mock(return_value=None)):
self.assertRaises(SystemExit, patroni_main)
@patch('pkgutil.iter_importers', Mock(return_value=[MockFrozenImporter()]))
@patch('sys.frozen', Mock(return_value=True), create=True)
@@ -89,7 +102,7 @@ class TestPatroni(unittest.TestCase):
@patch('os.getpid')
@patch('multiprocessing.Process')
@patch('patroni.patroni_main', Mock())
@patch('patroni.__main__.patroni_main', Mock())
def test_patroni_main(self, mock_process, mock_getpid):
mock_getpid.return_value = 2
_main()
@@ -181,8 +194,8 @@ class TestPatroni(unittest.TestCase):
self.p.ha.shutdown = Mock(side_effect=Exception)
self.p.shutdown()
def test_check_psycopg2(self):
def test_check_psycopg(self):
with patch.object(builtins, '__import__', Mock(side_effect=ImportError)):
self.assertRaises(SystemExit, check_psycopg2)
with patch('psycopg2.__version__', '2.5.3.dev1 a b c'):
self.assertRaises(SystemExit, check_psycopg2)
self.assertRaises(SystemExit, check_psycopg)
with patch.object(builtins, '__import__', mock_import):
self.assertRaises(SystemExit, check_psycopg)
+47 -32
View File
@@ -1,12 +1,14 @@
import datetime
import os
import psutil
import psycopg2
import re
import subprocess
import time
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
import patroni.psycopg as psycopg
from patroni.async_executor import CriticalTask
from patroni.dcs import Cluster, RemoteMember, SyncState
from patroni.exceptions import PostgresConnectionException, PatroniException
@@ -17,7 +19,7 @@ from patroni.utils import RetryFailedError
from six.moves import builtins
from threading import Thread, current_thread
from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg2_connect
from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg_connect
mtime_ret = {}
@@ -87,13 +89,13 @@ Data page checksum version: 0
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
class TestPostgresql(BaseTestPostgresql):
@patch('subprocess.call', Mock(return_value=0))
@patch('os.rename', Mock())
@patch('patroni.postgresql.CallbackExecutor', Mock())
@patch.object(Postgresql, 'get_major_version', Mock(return_value=130000))
@patch.object(Postgresql, 'get_major_version', Mock(return_value=140000))
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def setUp(self):
super(TestPostgresql, self).setUp()
@@ -203,6 +205,21 @@ class TestPostgresql(BaseTestPostgresql):
mock_postmaster.signal_stop.side_effect = [None, True]
self.assertTrue(self.p.stop(on_safepoint=mock_callback, stop_timeout=30))
@patch('time.sleep', Mock())
@patch.object(Postgresql, 'is_running', MockPostmaster)
@patch.object(Postgresql, '_wait_for_connection_close', Mock())
@patch.object(Postgresql, 'latest_checkpoint_location', Mock(return_value='7'))
def test__do_stop(self):
mock_callback = Mock()
with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down'})):
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
mock_callback.assert_called()
with patch.object(Postgresql, 'controldata',
Mock(return_value={'Database cluster state': 'shut down in recovery'})):
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shutting down'})):
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
def test_restart(self):
self.p.start = Mock(return_value=False)
self.assertFalse(self.p.restart())
@@ -243,8 +260,8 @@ class TestPostgresql(BaseTestPostgresql):
with patch('patroni.postgresql.config.ConfigHandler.primary_conninfo_params', Mock(return_value=conninfo)):
mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '1'
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
mock_get_pg_settings.return_value['primary_conninfo'][1] = 'host=1 passfile='\
+ re.sub(r'([\'\\ ])', r'\\\1', self.p.config._pgpass)
mock_get_pg_settings.return_value['primary_conninfo'][1] = 'host=1 target_session_attrs=read-write'\
+ ' passfile=' + re.sub(r'([\'\\ ])', r'\\\1', self.p.config._pgpass)
mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '0'
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': conninfo.copy()})
@@ -270,6 +287,8 @@ class TestPostgresql(BaseTestPostgresql):
mock_get_pg_settings.side_effect = Exception
with patch('patroni.postgresql.config.mtime', mock_mtime):
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
with patch.object(Postgresql, 'is_starting', Mock(return_value=True)):
self.assertEqual(self.p.config.check_recovery_conf(None), (False, False))
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=100000))
@patch.object(Postgresql, 'primary_conninfo', Mock(return_value='host=1'))
@@ -304,7 +323,7 @@ class TestPostgresql(BaseTestPostgresql):
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}})
self.p.follow(m)
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError))
def test__query(self):
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
self.p._state = 'restarting'
@@ -313,14 +332,14 @@ class TestPostgresql(BaseTestPostgresql):
def test_query(self):
self.p.query('select 1')
self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
self.assertRaises(psycopg2.ProgrammingError, self.p.query, 'blabla')
self.assertRaises(psycopg.ProgrammingError, self.p.query, 'blabla')
@patch.object(Postgresql, 'pg_isready', Mock(return_value=STATE_REJECT))
def test_is_leader(self):
self.assertTrue(self.p.is_leader())
self.p.reset_cluster_info_state(None)
with patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))):
self.assertRaises(PostgresConnectionException, self.p.is_leader)
self.assertFalse(self.p.is_leader())
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down',
'Latest checkpoint location': '0/1ADBC18',
@@ -328,7 +347,7 @@ class TestPostgresql(BaseTestPostgresql):
@patch('subprocess.Popen')
def test_latest_checkpoint_location(self, mock_popen):
mock_popen.return_value.communicate.return_value = (None, None)
self.assertEqual(self.p.latest_checkpoint_location(), '28163096')
self.assertEqual(self.p.latest_checkpoint_location(), 28163096)
# 9.3 and 9.4 format
mock_popen.return_value.communicate.side_effect = [
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
@@ -336,14 +355,14 @@ class TestPostgresql(BaseTestPostgresql):
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
(b'rmgr: Transaction len (rec/tot): 64/ 96, tx: 726, lsn: 0/01ADBBB8, prev 0/01ADBB70, ' +
b'bkp: 0000, desc: commit: 2021-02-26 11:19:37.900918 CET; inval msgs: catcache 11 catcache 10', None)]
self.assertEqual(self.p.latest_checkpoint_location(), '28163096')
self.assertEqual(self.p.latest_checkpoint_location(), 28163096)
mock_popen.return_value.communicate.side_effect = [
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
(b'rmgr: XLOG len (rec/tot): 0/ 32, tx: 0, lsn: 0/01ADBBB8, prev 0/01ADBBA0, ' +
b'bkp: 0000, desc: xlog switch ', None)]
self.assertEqual(self.p.latest_checkpoint_location(), '28163000')
self.assertEqual(self.p.latest_checkpoint_location(), 28163000)
# 9.5+ format
mock_popen.return_value.communicate.side_effect = [
(b'rmgr: XLOG len (rec/tot): 114/ 114, tx: 0, lsn: 0/01ADBC18, prev 0/018260F8, ' +
@@ -352,7 +371,7 @@ class TestPostgresql(BaseTestPostgresql):
b' oldest running xid 0; shutdown', None),
(b'rmgr: XLOG len (rec/tot): 24/ 24, tx: 0, lsn: 0/018260F8, prev 0/01826080, ' +
b'desc: SWITCH ', None)]
self.assertEqual(self.p.latest_checkpoint_location(), '25321720')
self.assertEqual(self.p.latest_checkpoint_location(), 25321720)
def test_reload(self):
self.assertTrue(self.p.reload())
@@ -415,7 +434,7 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster()))
def test_is_leader_exception(self):
self.p.start()
self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported"))
self.p.query = Mock(side_effect=psycopg.OperationalError("not supported"))
self.assertTrue(self.p.stop())
@patch('os.rename', Mock())
@@ -435,24 +454,20 @@ class TestPostgresql(BaseTestPostgresql):
def test_get_postgres_role_from_data_directory(self):
self.assertEqual(self.p.get_postgres_role_from_data_directory(), 'replica')
@patch('os.remove', Mock())
@patch('shutil.rmtree', Mock())
@patch('os.unlink', Mock(side_effect=OSError))
@patch('os.path.isdir', Mock(return_value=True))
@patch('os.path.exists', Mock(return_value=True))
def test_remove_data_directory(self):
def _symlink(src, dst):
if os.name != 'nt': # os.symlink under Windows needs admin rights skip it
os.symlink(src, dst)
os.makedirs(os.path.join(self.p.data_dir, 'foo'))
_symlink('foo', os.path.join(self.p.data_dir, 'pg_wal'))
os.makedirs(os.path.join(self.p.data_dir, 'foo_tsp'))
pg_tblspc = os.path.join(self.p.data_dir, 'pg_tblspc')
os.makedirs(pg_tblspc)
_symlink('../foo_tsp', os.path.join(pg_tblspc, '12345'))
self.p.remove_data_directory()
open(self.p.data_dir, 'w').close()
self.p.remove_data_directory()
_symlink('unexisting', self.p.data_dir)
with patch('os.unlink', Mock(side_effect=OSError)):
with patch('os.path.islink', Mock(return_value=True)):
self.p.remove_data_directory()
with patch('os.path.isfile', Mock(return_value=True)):
self.p.remove_data_directory()
with patch('os.path.islink', Mock(side_effect=[False, False, True, True])),\
patch('os.listdir', Mock(return_value=['12345'])),\
patch('os.path.realpath', Mock(side_effect=['../foo', '../foo_tsp'])):
self.p.remove_data_directory()
self.p.remove_data_directory()
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
def test_controldata(self):
@@ -544,7 +559,7 @@ class TestPostgresql(BaseTestPostgresql):
t.start()
t.join()
with patch.object(MockCursor, "execute", side_effect=psycopg2.Error):
with patch.object(MockCursor, "execute", side_effect=psycopg.Error):
self.assertIsNone(self.p.postmaster_start_time())
def test_check_for_startup(self):
@@ -707,7 +722,7 @@ class TestPostgresql(BaseTestPostgresql):
self.p.stop(on_safepoint=mock_callback)
mock_postmaster.is_running.side_effect = [True, False, False]
with patch.object(MockCursor, "execute", Mock(side_effect=psycopg2.Error)):
with patch.object(MockCursor, "execute", Mock(side_effect=psycopg.Error)):
self.p.stop(on_safepoint=mock_callback)
def test_terminate_starting_postmaster(self):
+13 -7
View File
@@ -73,7 +73,7 @@ class TestPostmasterProcess(unittest.TestCase):
# all processes successfully stopped
mock_children.return_value = [Mock()]
mock_children.return_value[0].kill.side_effect = psutil.Error
mock_children.return_value[0].kill.side_effect = psutil.NoSuchProcess(123)
self.assertTrue(proc.signal_kill())
# postmaster has gone before suspend
@@ -81,17 +81,17 @@ class TestPostmasterProcess(unittest.TestCase):
self.assertTrue(proc.signal_kill())
# postmaster has gone before we got a list of children
mock_suspend.side_effect = psutil.Error()
mock_suspend.side_effect = psutil.AccessDenied()
mock_children.side_effect = psutil.NoSuchProcess(123)
self.assertTrue(proc.signal_kill())
# postmaster has gone after we got a list of children
mock_children.side_effect = psutil.Error()
mock_children.side_effect = psutil.AccessDenied()
mock_kill.side_effect = psutil.NoSuchProcess(123)
self.assertTrue(proc.signal_kill())
# failed to kill postmaster
mock_kill.side_effect = psutil.AccessDenied(123)
mock_kill.side_effect = psutil.AccessDenied()
self.assertFalse(proc.signal_kill())
@patch('psutil.Process.__init__', Mock())
@@ -133,14 +133,20 @@ class TestPostmasterProcess(unittest.TestCase):
c2.cmdline = Mock(return_value=["postgres: postgres postgres [local] idle"])
c3 = Mock()
c3.cmdline = Mock(side_effect=psutil.NoSuchProcess(123))
mock_wait.return_value = ([], [c2])
with patch('psutil.Process.children', Mock(return_value=[c1, c2, c3])):
proc = PostmasterProcess(123)
self.assertIsNone(proc.wait_for_user_backends_to_close())
mock_wait.assert_called_with([c2])
self.assertIsNone(proc.wait_for_user_backends_to_close(1))
mock_wait.assert_called_with([c2], 1)
mock_wait.return_value = ([c2], [])
with patch('psutil.Process.children', Mock(return_value=[c1, c2, c3])):
proc = PostmasterProcess(123)
proc.wait_for_user_backends_to_close(1)
with patch('psutil.Process.children', Mock(side_effect=psutil.NoSuchProcess(123))):
proc = PostmasterProcess(123)
self.assertIsNone(proc.wait_for_user_backends_to_close())
self.assertIsNone(proc.wait_for_user_backends_to_close(None))
@patch('subprocess.Popen')
@patch('os.setsid', Mock(), create=True)
+1 -1
View File
@@ -157,6 +157,6 @@ class TestRaft(unittest.TestCase):
@patch('threading.Event')
def test_init(self, mock_event, mock_kvstore):
mock_kvstore.return_value.applied_local_log = False
mock_event.return_value.isSet.side_effect = [False, True]
mock_event.return_value.is_set.side_effect = [False, True]
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
'self_addr': '1', 'data_dir': self._TMP}))
+83 -15
View File
@@ -5,7 +5,7 @@ from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.rewind import Rewind
from six.moves import builtins
from . import BaseTestPostgresql, MockCursor, psycopg2_connect
from . import BaseTestPostgresql, MockCursor, psycopg_connect
class MockThread(object):
@@ -47,7 +47,7 @@ def mock_single_user_mode(self, communicate, options):
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
class TestRewind(BaseTestPostgresql):
def setUp(self):
@@ -66,7 +66,7 @@ class TestRewind(BaseTestPostgresql):
def test_pg_rewind(self):
r = {'user': '', 'host': '', 'port': '', 'database': '', 'password': ''}
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000)),\
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=150000)),\
patch.object(CancellableSubprocess, 'call', Mock(return_value=None)):
with patch('subprocess.check_output', Mock(return_value=b'boo')):
self.assertFalse(self.r.pg_rewind(r))
@@ -102,6 +102,11 @@ class TestRewind(BaseTestPostgresql):
@patch.object(Postgresql, 'start', Mock())
def test_execute(self, mock_checkpoint):
self.r.execute(self.leader)
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000)):
self.r.execute(self.leader)
with patch.object(MockCursor, 'fetchone', Mock(side_effect=Exception)):
self.r.execute(self.leader)
with patch.object(Rewind, 'pg_rewind', Mock(return_value=False)):
mock_checkpoint.side_effect = ['1', '', '', '']
self.r.execute(self.leader)
@@ -143,11 +148,16 @@ class TestRewind(BaseTestPostgresql):
mock_check_leader_is_not_in_recovery.return_value = True
self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.trigger_check_diverged_lsn()
with patch('psycopg2.connect', Mock(side_effect=Exception)):
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[('', 3, '0/0'), ('', b'4\t0/40159C0\tn\n')])):
self.assertTrue(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.reset_state()
self.r.trigger_check_diverged_lsn()
with patch('patroni.psycopg.connect', Mock(side_effect=Exception)):
self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.trigger_check_diverged_lsn()
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[('', 3, '0/0'), ('', b'3\t0/40159C0\tn\n')])):
self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[('', 3, '0/0'), ('', b'1\t0/40159C0\tn\n')])):
self.assertTrue(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.reset_state()
self.r.trigger_check_diverged_lsn()
with patch.object(MockCursor, 'fetchone', Mock(return_value=('', 1, '0/0'))):
with patch.object(Rewind, '_get_local_timeline_lsn', Mock(return_value=(True, 1, '0/0'))):
@@ -208,6 +218,62 @@ class TestRewind(BaseTestPostgresql):
self.r.cleanup_archive_status()
self.r.cleanup_archive_status()
@patch('os.path.isfile', Mock(return_value=True))
@patch('shutil.move', Mock(side_effect=OSError))
@patch('patroni.postgresql.rewind.logger.info')
def test_archive_ready_wals(self, mock_logger_info):
with patch('os.listdir', Mock(side_effect=OSError)), \
patch.object(Postgresql, 'get_guc_value', Mock(side_effect=['on', 'command %f'])):
self.r._archive_ready_wals()
mock_logger_info.assert_not_called()
# each assert_not_called() calls get_guc_value('archive_mode') + get_guc_value('archive_command')
get_guc_value_res = [
'', 'command %f',
'on', '',
]
with patch.object(Postgresql, 'get_guc_value', Mock(side_effect=get_guc_value_res)):
for _ in range(len(get_guc_value_res)//2):
self.r._archive_ready_wals()
mock_logger_info.assert_not_called()
with patch('os.listdir', Mock(return_value=['000000000000000000000000.ready'])):
# successful archive_command call
with patch.object(CancellableSubprocess, 'call', Mock(return_value=0)):
get_guc_value_res = [
'on', 'command %f',
'always', 'command %f',
]
with patch.object(Postgresql, 'get_guc_value', Mock(side_effect=get_guc_value_res)):
for _ in range(len(get_guc_value_res)//2):
self.r._archive_ready_wals()
mock_logger_info.assert_called_once()
self.assertEqual(('Trying to archive %s: %s',
'000000000000000000000000', 'command 000000000000000000000000'),
mock_logger_info.call_args[0])
mock_logger_info.reset_mock()
# failed archive_command call
with patch.object(CancellableSubprocess, 'call', Mock(return_value=1)):
with patch.object(Postgresql, 'get_guc_value', Mock(side_effect=['on', 'command %f'])):
self.r._archive_ready_wals()
self.assertEqual(('Trying to archive %s: %s',
'000000000000000000000000', 'command 000000000000000000000000'),
mock_logger_info.call_args_list[0][0])
self.assertEqual(('Failed to archive WAL segment %s', '000000000000000000000000'),
mock_logger_info.call_args_list[1][0])
mock_logger_info.reset_mock()
wal_files_to_skip = [
'000000000000000000000000.done',
'000000000000000000000001.partial.done',
'002.ready',
'U00000000000000000000001.ready',
]
with patch('os.listdir', Mock(return_value=wal_files_to_skip)):
self.r._archive_ready_wals()
mock_logger_info.assert_not_called()
@patch('os.unlink', Mock())
@patch('os.listdir', Mock(return_value=[]))
@patch('os.path.isfile', Mock(return_value=True))
@@ -219,18 +285,20 @@ class TestRewind(BaseTestPostgresql):
@patch('patroni.postgresql.rewind.Thread', MockThread)
@patch.object(Postgresql, 'controldata')
@patch.object(Postgresql, 'checkpoint')
def test_ensure_checkpoint_after_promote(self, mock_checkpoint, mock_controldata):
mock_checkpoint.return_value = None
@patch.object(Postgresql, 'get_master_timeline')
def test_ensure_checkpoint_after_promote(self, mock_get_master_timeline, mock_checkpoint, mock_controldata):
mock_controldata.return_value = {"Latest checkpoint's TimeLineID": 1}
mock_get_master_timeline.return_value = 1
self.r.ensure_checkpoint_after_promote(Mock())
self.r.reset_state()
mock_get_master_timeline.return_value = 2
mock_checkpoint.return_value = 0
self.r.ensure_checkpoint_after_promote(Mock())
self.r.ensure_checkpoint_after_promote(Mock())
self.r.reset_state()
mock_controldata.return_value = {"Latest checkpoint's TimeLineID": 1}
mock_controldata.side_effect = TypeError
mock_checkpoint.side_effect = Exception
self.r.ensure_checkpoint_after_promote(Mock())
self.r.ensure_checkpoint_after_promote(Mock())
self.r.reset_state()
mock_controldata.side_effect = TypeError
self.r.ensure_checkpoint_after_promote(Mock())
self.r.ensure_checkpoint_after_promote(Mock())
+55 -26
View File
@@ -1,20 +1,22 @@
import mock
import os
import psycopg2
import unittest
from mock import Mock, PropertyMock, patch
from threading import Thread
from patroni import psycopg
from patroni.dcs import Cluster, ClusterConfig, Member
from patroni.postgresql import Postgresql
from patroni.postgresql.slots import SlotsHandler, fsync_dir
from patroni.postgresql.slots import SlotsAdvanceThread, SlotsHandler, fsync_dir
from . import BaseTestPostgresql, psycopg2_connect, MockCursor
from . import BaseTestPostgresql, psycopg_connect, MockCursor
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Thread, 'start', Mock())
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
class TestSlotsHandler(BaseTestPostgresql):
@@ -27,6 +29,9 @@ class TestSlotsHandler(BaseTestPostgresql):
super(TestSlotsHandler, self).setUp()
self.s = self.p.slots_handler
self.p.start()
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
self.cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12345})
def test_sync_replication_slots(self):
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
@@ -34,13 +39,15 @@ class TestSlotsHandler(BaseTestPostgresql):
'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'test_3': 10})
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
self.s.sync_replication_slots(cluster, False)
self.p.set_role('standby_leader')
self.s.sync_replication_slots(cluster, False)
self.p.set_role('replica')
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)):
self.s.sync_replication_slots(cluster, False)
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)),\
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
self.s.sync_replication_slots(cluster, False, paused=True)
mock_drop.assert_not_called()
self.p.set_role('master')
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
self.s.sync_replication_slots(cluster, False)
@@ -81,39 +88,46 @@ class TestSlotsHandler(BaseTestPostgresql):
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test__ensure_logical_slots_replica(self):
self.p.set_role('replica')
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12346})
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
self.cluster.slots['ls'] = 12346
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock()):
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
self.s._schedule_load_slots = False
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.errors.UndefinedFile)):
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
cluster.slots['ls'] = 'a'
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))),\
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
self.cluster.slots['ls'] = 'a'
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
def test_copy_logical_slots(self):
self.s.copy_logical_slots(self.leader, ['foo'])
self.cluster.config.data['slots']['ls']['database'] = 'b'
self.s.copy_logical_slots(self.cluster, ['ls'])
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
self.s.copy_logical_slots(self.cluster, ['foo'])
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test_check_logical_slots_readiness(self):
self.s.copy_logical_slots(self.leader, ['ls'])
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12345})
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
self.s.check_logical_slots_readiness(cluster, False, None)
self.s.copy_logical_slots(self.cluster, ['ls'])
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))),\
patch.object(MockCursor, 'fetchone', Mock(side_effect=Exception)):
self.assertIsNone(self.s.check_logical_slots_readiness(self.cluster, False, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))),\
patch.object(MockCursor, 'fetchone', Mock(return_value=(False,))):
self.assertIsNone(self.s.check_logical_slots_readiness(self.cluster, False, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('ls', 100)]))):
self.s.check_logical_slots_readiness(self.cluster, False, None)
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test_on_promote(self):
self.s.copy_logical_slots(self.leader, ['ls'])
self.s.schedule_advance_slots({'foo': {'bar': 100}})
self.s.copy_logical_slots(self.cluster, ['ls'])
self.s.on_promote()
@unittest.skipIf(os.name == 'nt', "Windows not supported")
@@ -122,3 +136,18 @@ class TestSlotsHandler(BaseTestPostgresql):
@patch('os.fsync', Mock(side_effect=OSError))
def test_fsync_dir(self):
self.assertRaises(OSError, fsync_dir, 'foo')
def test_slots_advance_thread(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
self.s.schedule_advance_slots({'foo': {'bar': 100}})
self.s._advance.sync_slots()
with patch.object(SlotsAdvanceThread, 'sync_slots', Mock(side_effect=Exception)):
self.s._advance._condition.wait = Mock()
self.assertRaises(Exception, self.s._advance.run)
with patch.object(SlotsHandler, 'get_local_connection_cursor', Mock(side_effect=Exception)):
self.s.schedule_advance_slots({'foo': {'bar': 100}})
self.s._advance.sync_slots()
+20 -17
View File
@@ -63,6 +63,7 @@ config = {
"postgresql": {
"listen": "127.0.0.2,::1:543",
"connect_address": "127.0.0.2:543",
"proxy_address": "127.0.0.2:5433",
"authentication": {
"replication": {"username": "user"},
"superuser": {"username": "user"},
@@ -141,14 +142,14 @@ class TestValidator(unittest.TestCase):
del directories[:]
def test_empty_config(self, mock_out, mock_err):
schema({})
output = mock_out.getvalue()
errors = schema({})
output = "\n".join(errors)
expected = list(sorted(['name', 'postgresql', 'restapi', 'scope'] + available_dcs))
self.assertEqual(expected, parse_output(output))
def test_complete_config(self, mock_out, mock_err):
schema(config)
output = mock_out.getvalue()
errors = schema(config)
output = "\n".join(errors)
self.assertEqual(['postgresql.bin_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
def test_bin_dir_is_file(self, mock_out, mock_err):
@@ -156,10 +157,11 @@ class TestValidator(unittest.TestCase):
files.append(config["postgresql"]["bin_dir"])
c = copy.deepcopy(config)
c["restapi"]["connect_address"] = 'False:blabla'
c["postgresql"]["listen"] = '*:543'
c["etcd"]["hosts"] = ["127.0.0.1:2379", "1244.0.0.1:2379", "127.0.0.1:invalidport"]
c["kubernetes"]["pod_ip"] = "127.0.0.1111"
schema(c)
output = mock_out.getvalue()
errors = schema(c)
output = "\n".join(errors)
self.assertEqual(['etcd.hosts.1', 'etcd.hosts.2', 'kubernetes.pod_ip', 'postgresql.bin_dir',
'postgresql.data_dir', 'raft.bind_addr', 'raft.self_addr',
'restapi.connect_address'], parse_output(output))
@@ -176,8 +178,8 @@ class TestValidator(unittest.TestCase):
c["etcd"]["host"] = "127.0.0.1:237"
c["postgresql"]["listen"] = "127.0.0.1:5432"
with patch('patroni.validator.open', mock_open(read_data='9')):
schema(c)
output = mock_out.getvalue()
errors = schema(c)
output = "\n".join(errors)
self.assertEqual(['consul.host', 'etcd.host', 'postgresql.bin_dir', 'postgresql.data_dir', 'postgresql.listen',
'raft.bind_addr', 'raft.self_addr', 'restapi.connect_address'], parse_output(output))
@@ -195,8 +197,8 @@ class TestValidator(unittest.TestCase):
files.append(os.path.join(config["postgresql"]["bin_dir"], "postgres"))
files.append(os.path.join(config["postgresql"]["bin_dir"], "pg_isready"))
with patch('patroni.validator.open', mock_open(read_data='12')):
schema(config)
output = mock_out.getvalue()
errors = schema(config)
output = "\n".join(errors)
self.assertEqual(['raft.bind_addr', 'raft.self_addr'], parse_output(output))
@patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 12.1"))
@@ -208,11 +210,12 @@ class TestValidator(unittest.TestCase):
files.append(os.path.join(config["postgresql"]["data_dir"], "PG_VERSION"))
c = copy.deepcopy(config)
c["etcd"]["hosts"] = []
c["postgresql"]["listen"] = '127.0.0.2,*:543'
del c["postgresql"]["bin_dir"]
with patch('patroni.validator.open', mock_open(read_data='11')):
schema(c)
output = mock_out.getvalue()
self.assertEqual(['etcd.hosts', 'postgresql.data_dir',
errors = schema(c)
output = "\n".join(errors)
self.assertEqual(['etcd.hosts', 'postgresql.data_dir', 'postgresql.listen',
'raft.bind_addr', 'raft.self_addr'], parse_output(output))
@patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 12.1"))
@@ -224,8 +227,8 @@ class TestValidator(unittest.TestCase):
c = copy.deepcopy(config)
del c["postgresql"]["bin_dir"]
with patch('patroni.validator.open', mock_open(read_data='11')):
schema(c)
output = mock_out.getvalue()
errors = schema(c)
output = "\n".join(errors)
self.assertEqual(['postgresql.data_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
def test_data_dir_is_empty_string(self, mock_out, mock_err):
@@ -236,7 +239,7 @@ class TestValidator(unittest.TestCase):
c["postgresql"]["pg_hba"] = ""
c["postgresql"]["data_dir"] = ""
c["postgresql"]["bin_dir"] = ""
schema(c)
output = mock_out.getvalue()
errors = schema(c)
output = "\n".join(errors)
self.assertEqual(['kubernetes', 'postgresql.bin_dir', 'postgresql.data_dir',
'postgresql.pg_hba', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
+5 -4
View File
@@ -1,14 +1,15 @@
import psycopg2
import subprocess
import unittest
import patroni.psycopg as psycopg
from mock import Mock, PropertyMock, patch, mock_open
from patroni.scripts import wale_restore
from patroni.scripts.wale_restore import WALERestore, main as _main, get_major_version
from six.moves import builtins
from threading import current_thread
from . import MockConnect, psycopg2_connect
from . import MockConnect, psycopg_connect
wale_output_header = (
b'name\tlast_modified\t'
@@ -34,7 +35,7 @@ WALE_TEST_RETRIES = 2
@patch('os.makedirs', Mock(return_value=True))
@patch('os.path.exists', Mock(return_value=True))
@patch('os.path.isdir', Mock(return_value=True))
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('subprocess.check_output', Mock(return_value=wale_output))
class TestWALERestore(unittest.TestCase):
@@ -57,7 +58,7 @@ class TestWALERestore(unittest.TestCase):
with patch('subprocess.check_output', Mock(return_value=wale_output.replace(b'167772160', b'1'))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('psycopg2.connect', Mock(side_effect=psycopg2.Error("foo"))):
with patch('patroni.psycopg.connect', Mock(side_effect=psycopg.Error("foo"))):
save_no_master = self.wale_restore.no_master
save_master_connection = self.wale_restore.master_connection
+1 -1
View File
@@ -35,7 +35,7 @@ def mock_ioctl(fd, op, arg=None, mutate_flag=False):
sys.stderr.write("Ioctl %d %d %r\n" % (fd, op, arg))
if op == linuxwd.WDIOC_GETSUPPORT:
sys.stderr.write("Get support\n")
assert(mutate_flag is True)
assert (mutate_flag is True)
arg.options = sum(map(linuxwd.WDIOF.get, ['SETTIMEOUT', 'KEEPALIVEPING']))
arg.identity = (ctypes.c_ubyte*32)(*map(ord, 'Mock Watchdog'))
elif op == linuxwd.WDIOC_GETTIMEOUT:
+14 -6
View File
@@ -6,8 +6,8 @@ from kazoo.client import KazooClient, KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError
from kazoo.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import KeeperState, ZnodeStat
from mock import Mock, patch
from patroni.dcs.zookeeper import Leader, PatroniKazooClient,\
from mock import Mock, PropertyMock, patch
from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient,\
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
@@ -124,9 +124,11 @@ class TestPatroniSequentialThreadingHandler(unittest.TestCase):
self.assertIsNotNone(self.handler.create_connection((), 40))
self.assertIsNotNone(self.handler.create_connection(timeout=40))
@patch.object(SequentialThreadingHandler, 'select', Mock(side_effect=ValueError))
def test_select(self):
self.assertRaises(select.error, self.handler.select)
with patch.object(SequentialThreadingHandler, 'select', Mock(side_effect=ValueError)):
self.assertRaises(select.error, self.handler.select)
with patch.object(SequentialThreadingHandler, 'select', Mock(side_effect=IOError)):
self.assertRaises(Exception, self.handler.select)
class TestPatroniKazooClient(unittest.TestCase):
@@ -144,7 +146,8 @@ class TestZooKeeper(unittest.TestCase):
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
def setUp(self):
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10})
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
'set_acls': {'CN=principal2': ['ALL']}})
def test_session_listener(self):
self.zk.session_listener(KazooState.SUSPENDED)
@@ -173,6 +176,8 @@ class TestZooKeeper(unittest.TestCase):
self.assertRaises(ZooKeeperError, self.zk.get_cluster)
cluster = self.zk.get_cluster(True)
self.assertIsInstance(cluster.leader, Leader)
self.zk.status_watcher(None)
self.zk.get_cluster()
self.zk.touch_member({'foo': 'foo'})
self.zk._name = 'bar'
self.zk.status_watcher(None)
@@ -213,6 +218,7 @@ class TestZooKeeper(unittest.TestCase):
self.zk.touch_member({'retry': 'retry'})
self.zk._fetch_cluster = True
self.zk.get_cluster()
self.zk.touch_member({'retry': 'retry'})
self.zk.touch_member({'conn_url': 'postgres://repuser:rep-pass@localhost:5434/postgres',
'api_url': 'http://127.0.0.1:8009/patroni'})
@@ -224,6 +230,7 @@ class TestZooKeeper(unittest.TestCase):
def test_update_leader(self):
self.assertTrue(self.zk.update_leader(12345))
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
def test_write_leader_optime(self):
self.zk.last_lsn = '0'
self.zk.write_leader_optime('1')
@@ -232,6 +239,7 @@ class TestZooKeeper(unittest.TestCase):
with patch.object(MockKazooClient, 'set_async', Mock()):
self.zk.write_leader_optime('2')
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk.get_cluster()
self.zk.write_leader_optime('3')
def test_delete_cluster(self):
@@ -239,7 +247,7 @@ class TestZooKeeper(unittest.TestCase):
def test_watch(self):
self.zk.watch(None, 0)
self.zk.event.isSet = Mock(return_value=True)
self.zk.event.is_set = Mock(return_value=True)
self.zk._fetch_status = False
self.zk.watch(None, 0)