Compare commits

..
Author SHA1 Message Date
Alexander Kukushkin c0033399a5 Merge branch 'release/v3.3.0' of github.com:zalando/patroni into feature/citus-secondaries 2024-04-04 08:50:27 +02:00
Polina Bungina ce05cb9a10 Add release notes for 3.3.0 2024-04-03 21:16:32 +02:00
Alexander Kukushkin 4469c1c390 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2024-04-03 09:38:12 +02:00
Alexander Kukushkin 68c7b11970 Improve unit-tests code coverage 2024-04-02 15:46:59 +02:00
Alexander Kukushkin ced75cfe12 Bump pyright and "solve" reported "issues"
Most of them are related to partially unknown types of values from empty
dict or list. To solve it for the empty dict we use `EMPTY_DICT` object of
newly introduced `_FrozenDict` class.
2024-04-02 15:15:52 +02:00
Alexander Kukushkin 25ee08f62f Make sure unit tests not rely on filesystem state 2024-04-02 15:02:12 +02:00
Alexander Kukushkin 3e2f553c81 Make sure tests are not making external calls
and pass url with scheme to urllib3 to avoid warnings
2024-04-02 15:02:12 +02:00
Alexander KukushkinandGitHub 3fd7c98d2b Compatibility with ydiff>=1.3 (#3042)
PatchStream class was removed as it was effectively doing nothing useful.
2024-04-02 14:48:58 +02:00
Alexander Kukushkin ca7188bfdb Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2024-04-02 12:13:12 +02:00
Alexander KukushkinandGitHub d7454f7bcd Use target_session_attrs only when multiple hosts in standby_cluster (#3040)
Actually comment in the code was already saying that, but on practice it didn't happen.

It should help #3039
2024-04-02 11:59:57 +02:00
WaynervandGitHub ceb2965ab8 Use importlib_resources to read validators file (#3018)
When packaged into pyz (zip file), resources are not directly available on filesystem and therefore we can't always rely on os.listdir() and open() to enumerate and read them.

We are going to use importlib.resources() to solve this problem, except python 3.8 and older, where there is no function (files()) available to enumerate resources. For legacy (3.8 actually becomes EOL in October 2024) python versions we are going to use os.listdir() as a fallback.

Close #3017
2024-04-02 09:00:37 +02:00
Polina BunginaandGitHub ae53260030 Extend behave tests with nostream feature (#3036)
Check state and permanent logical replication slots behaviour
2024-03-29 12:54:40 +01:00
Polina BunginaandGitHub 9b237b332e Set global_config from dynamic_config if DCS data is empty (#3038)
Fix the oversight of 193c73f
We need to set global config from the local cache if cluster.config is not initialized.
If there is nothing written into the DCS (yet), we need the setup info for the decision making (e.g., if it is a standby cluster)
2024-03-28 08:15:58 +01:00
Grigory SmolkinandGitHub b09af642e6 Disable WAL streaming on standby node via new boolean tag "nostream" (#2842)
Add support for ``nostream`` tag. If set to ``true`` the node will not use replication protocol to stream WAL. It will rely instead on archive recovery (if ``restore_command`` is configured) and ``pg_wal``/``pg_xlog`` polling. It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas. Setting this tag on primary node has no effect.
2024-03-20 10:10:53 +01:00
IsraelandGitHub 014777b20a Refactor Barman scripts and add a sub-command to switch Barman config (#3016)
We currently have a script named `patroni_barman_recover` in Patroni, which is intended to be used as a custom bootstrap method, or as a custom replica creation method.

Now there is need of one more Barman related script in Patroni to handle switching of config models in Barman upon `on_role_change` events.

However, instead of creating another Patroni script, let's say `patroni_barman_config_switch`, and duplicating a lot of logic in the code, we decided to refactor the code so:

* Instead of two separate scripts (`patroni_barman_recover` and `patroni_barman_config_switch`), we have a single script (`patroni_barman`) with 2 sub-commands (`recover` and `config-switch`)

This is the overview of changes that have been performed:

* File `patroni.scripts.barman_recover` has been removed, and its logic has been split into a few files:
  * `patroni.scripts.barman.cli`: handles the entrypoint of the new `patroni_barman` command, exposing the argument parser and calling the appropriate functions depending on the sub-command
  * `patroni.scripts.barman.utils`: implements utilitary enums, functions and classes wich can be used by `cli` and by sub-commands implementation:
    * retry mechanism
    * logging set up
    * communication with pg-backup-api
  * `patroni.scripts.barman.recover`: implements the `recover` sub-command only
* File `patroni.tests.test_barman_recover` has been renamed as `patroni.tests.test_barman`
* File `patroni.scripts.barman.config_switch` was created to implement the `config-switch` sub-command only
* `setup.py` has been changed so it generates a `patroni_barman` application instead of `patroni_barman_recover`
* Docs and unit tests were updated accordingly

References: PAT-154.
2024-03-20 09:04:55 +01:00
Alexander KukushkinandGitHub a8cfd46801 Retry one time on Etcd3 auth error (#3026)
But do it only in case if we didn't authenticate right before executing a request. Previously retries only happened when the caller was executed with `Retry.__call__()`, which is not the case for methods like `set_failover_value()` or `set_config_value()`. Also, it seems that existing watchers aren't affected, therefore we will not restart them after reauthentication.

In addition to that fix issues with `Retry.ensure_deadline(0)`:
1. the return value was ignored
2. we don't have to set `Retry.deadline` attr, it is not used anywhere

Close https://github.com/zalando/patroni/issues/3023
2024-03-07 12:01:35 +01:00
Junwang ZhaoandGitHub fd3e3ca472 add missing busybox install command (#3029)
before this patch, when execute `ps` inside container, we see
the following error:

postgres@a97c9e438eae:~$ ps
bash: ps: command not found

Signed-off-by: Zhao Junwang <[email protected]>
2024-03-06 08:06:47 +01:00
zhjwpkuandGitHub e131065d74 rename citus_handler to mpp_handler (#2991)
obey the following 5 meanings of terminology _cluster_ in Patroni.

1. PostgreSQL cluster: a cluster of postgresql instances which have the same system identifier.
2. MPP cluster: a cluster of PostgreSQL clusters that one of them acts as Coodinator and others act as workers.
3. Coordinator cluster: a PostgreSQL cluster which act the role of 'coordinator' within a MPP cluster.
4. Worker cluster: a PostgreSQL cluster which act the role 'worker' within a MPP cluster.
5. Patroni cluster: all cluster managed by Patroni can be called Patroni cluster, but we usually use this term to refering a single PostgreSQL cluster or an MPP cluster.
2024-02-28 06:16:20 +01:00
Polina BunginaandGitHub bdd02324b4 Add pending restart reason information (#2978)
Provide info about the PG parameters that caused "pending restart"
flag to be set. Both `patronictl list` and `/patroni` REST API endpoint
now show the parameters names and the diff as the "pending restart
reason".
2024-02-14 08:54:20 +01:00
IsraelandGitHub 7adfc0dbe7 Patroni doesn't filter out some not allowed options from pg_basebackup (#3015)
When running `pg_basebackup` to bootstrap a replica, Patroni sanitizes
the custom user options that come from `postgresql.basebackup` configuration
section using the `process_user_options` method.

However, there is a bug in that method: it filters out not allowed options
that are in the format `- setting`, but not the ones in the format
`- setting: value` from `postgresql.basebackup`.

An example of that issue is the `dbname` setting. If you specify something
like this in the configuration file:

```yaml
postgresql:
  basebackup:
    - dbname: "host=RANDOM"
```

You end up with `--dbname` being specified twice for `pg_basebackup`, with
`--dbname='host=RANDOM'` taking precedence as it comes up later in the
command.

This commit fixes that issue by adding a `continue` statement when
the setting in format `- setting: value` is not allowed, thus skipping
it.

---------

Signed-off-by: Israel Barth Rubio <[email protected]>
2024-02-06 08:36:11 +01:00
Polina BunginaandGitHub f6943a859d Improve logging for Pg param change (#3008)
* Convert old value to a human-readable format
* Add log line about pg_controldata/global config mismatch that causes
  pending restart flag to be set
2024-01-29 10:44:25 +01:00
Alexander KukushkinandGitHub e532f9dc38 Fix bugs introduced in the jsonlog implementation (#3006)
1. RotatingFileHandler is a child of StreamHandler, therefore we can't rely on `not isinstance(handler, logging.StreamHandler)`.
2. If the legacy version of `python-json-logger` is installed (that doesn't support rename_fields or static_fields), we want do what is possible rather than fail with the exception.

Besides that:
1. improve code coverage
2. make unit tests pass without python-json-logger installed or if only some old version is installed.
2024-01-29 10:37:15 +01:00
688c85389c Release v3.2.2 (#3007)
- update release notes
- bump Patroni version
- bump pyright version and fix reported issues
- improve compatibility with legacy psycopg2

Co-authored-by: Polina Bungina <[email protected]>
2024-01-17 08:31:08 +01:00
علی سالمیandGitHub 5c4ee30dae Add JSON log format to logging configuration (#2982)
Now patroni can be configured as bellow to log in json format.

```yaml
log:
  type: json
  format:
    - asctime: '@timestamp'
    - levelname: level
    - message
    - module
    - name: logger_name
  static_fields:
    app: patroni
```

This config produce this log:

```json
{
  "@timestamp": "2023-12-14 19:51:24,872",
  "level": "INFO",
  "message": "Lock owner: None; I am postgresql1",
  "module": "ha",
  "app": "patroni",
  "logger_name": "patroni.ha"
}
```
2024-01-16 10:42:48 +01:00
Polina BunginaandGitHub 266cdc4810 Fixes around pending_restart flag (#3003)
* Do not set pending_restart flag if hot_standby is set to 'off' during a custom bootstrap (even though we will have this flag actually set in PG, this configuration parameter is irrelevant on primary and there is no actual need for restart)
* Skip hot_standby and wal_log_hints when querying parameters pending restart on config reload. They actually can be changed manually (e.g. via ALTER SYSTEM) and it will cause the pending_restart state in PG but Patroni anyway always passes those params to postmaster as command line options. And there they only can have one value - 'on' (except on primary when performing custom bootstrap)
2024-01-16 10:32:28 +01:00
Alexander KukushkinandGitHub 2ac1efea54 Optimize priority failover behave tests (#3004)
1. get rid of useless sleep calls
2. call `POST /failover` on the node where we want to failover to
2024-01-15 12:03:14 +01:00
Alexander KukushkinandGitHub 5d8c2fb559 Restore recovery GUCs when joining running standby (#2998)
Close https://github.com/zalando/patroni/issues/2993
2024-01-08 08:35:53 +01:00
Alexander Kukushkin 27a1a39f75 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2024-01-05 10:20:11 +01:00
IsraelandGitHub 4e5b2ee249 Close the doors for a possible future bug in the config generator (#3000)
The `AbstractConfigGenerator._format_config` method was missing a comma in the declaration of a tuple. As a consequence it was concatenating the strings `ctl` and `citus` instead of creating two separate items in the tuple.

There is currently no observed bug from that issue in the code because the template configuration created by the method `AbstractConfigGenerator.get_template_config` doesn't include either of `ctl` or `citus` keys.

However, it is still important that we close the doors for possible future bugs that would come up if we ever attempt to use either of those keys in the template, for example.

References: PAT-231.
2024-01-04 12:30:28 +01:00
Sophia RuanandGitHub 3390ee9dea call freeze_support in main module to solve pyinstaller frozen issue (#2996)
Close #2995
2024-01-04 12:30:03 +01:00
Polina BunginaandGitHub 71ccf91e36 Don't filter out contradictory nofailover tag (#2992)
* Ensure that nofailover will always be used if both nofailover and
failover_priority tags are provided
* Call _validate_failover_tags from reload_local_configuration() as well
* Properly check values in the _validate_failover_tags(): nofailover value should be casted to boolean like it is done when accessed in other places
2024-01-02 09:30:18 +01:00
zhjwpkuandGitHub 8acefefc42 Fix Citus bootstrap - CREATE DATABASE cannot be executed from a function (#2994)
This was introduced by #2990: pod cannot be started and show the
following logs:

```
2023-12-26 03:29:25.569 UTC [47] CONTEXT:  SQL statement "CREATE DATABASE "citus""
        PL/pgSQL function inline_code_block line 5 at SQL statement
2023-12-26 03:29:25.569 UTC [47] STATEMENT:  DO $$
        BEGIN
            PERFORM * FROM pg_catalog.pg_database WHERE datname = 'citus';
            IF NOT FOUND THEN
                CREATE DATABASE "citus";
            END IF;
        END;$$
2023-12-26 03:29:25,570 ERROR: post_bootstrap
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/dist-packages/patroni/postgresql/bootstrap.py", line 474, in post_bootstrap
    self._postgresql.citus_handler.bootstrap()
  File "/usr/local/lib/python3.11/dist-packages/patroni/postgresql/mpp/citus.py", line 401, in bootstrap
    cur.execute(sql.encode('utf-8'))
psycopg2.errors.ActiveSqlTransaction: CREATE DATABASE cannot be executed from a function
CONTEXT:  SQL statement "CREATE DATABASE "citus""
PL/pgSQL function inline_code_block line 5 at SQL statement
```
---------

Signed-off-by: Zhao Junwang <[email protected]>
2023-12-29 09:01:46 +01:00
Alexander Kukushkin f99fff6c6a Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-12-21 09:42:29 +01:00
Alexander KukushkinandGitHub dd548c4964 Create citus database and extension idempotently (#2990)
Consider a task: we want to create an extension _before_ citus in a database. Currently `post_bootstrab` script is executed before `CitusHandler.bootstrap()` method, which seems to allow doing that, but in fact `CitusHandler.bootstrap()` will fail to create already existing database and as a result the whole bootstrap will fail.

Changing the order of execution of `post_bootstrab` hook and `CitusHandler.bootstrap()` seems to be useless, because it will not allow creating another extension _before_ citus. Therefore the only way of solving it is making CREATE DATABASE and CREATE EXTENSION idempotent. It will allow to create citus database and all dependencies from the `post_bootstrab` hook.
2023-12-21 09:25:51 +01:00
bcfd8438a5 Abstract CitusHandler and decouple it from configuration (#2950)
the main issue was that the configuration for Citus handler and for DCS existed in two places, while ideally AbstractDCS should not know many details about what kind of MPP is in use.

To solve the problem we first dynamically create an object implementing AbstractMPP interfaces, which is a configuration for DCS. Later this object is used to instantiate the class implementing AbstractMPPHandler interface.

This is just a starting point, which does some heavy lifting. As a next steps all kind of variables named after Citus in files different from patroni/postgres/mpp/citus.py should be renamed.

In other words this commit takes over the most complex part of #2940, which was never implemented.

Co-authored-by: zhjwpku <[email protected]>
2023-12-21 08:58:26 +01:00
Alexander KukushkinandGitHub 5c3e1a693e Implement validation of the log section (#2989)
Somehow it was always forgotten.
2023-12-20 10:49:33 +01:00
Polina BunginaandGitHub 206ee91b07 Exclude leader from failover candidates in ctl (#2983)
Exclude actual leader (not the passed leader argument) from the
candidates list in the `patronictl failover` prompt.
Abort `patronictl failover` execution if candidate specified is
the same as the current cluster leader
2023-12-20 09:54:04 +01:00
Polina BunginaandGitHub c1ee99d81d Update PG version in a couple of places (#2986)
* All dockerfiles to use PG16 by default
* PGVERSION env in the test pipelines to 16.1-1 by default
* 11->14 in the dcs-pg mapping for test pipelines
* Code comments fixes
2023-12-18 10:44:05 +01:00
Polina BunginaandGitHub f0719d148c Actually allow failover to an async candidate in sync mode (#2980) 2023-12-13 08:40:47 +01:00
Polina BunginaandGitHub efdedc7049 Reload postgres config if a server param was reset (#2975)
Fix the case when a parameter value was changed and then reset back to
the initial value without restart - before this fix, the second change
was not reflected in the Postgres config.
This commit also includes the related unit test refactoring.
2023-12-06 15:57:05 +01:00
Alexander KukushkinandGitHub bbddca6a76 Use consistent read when fetching just updated sync key (#2974)
Consul doesn't provide any interface to immediately get `ModifyIndex` for the key that we just updated, therefore we have to perform an explicit read operation. By default stale reads are allowed and sometimes we may read stale data. As a result write_sync_state() call was considered as failed. To mitigate the problem we switch to `consistent` reads when that executed after update of the `/sync` key.

Close #2972
2023-12-06 15:55:51 +01:00
Alexander KukushkinandGitHub a4e0a2220d Disable SSL for MacOS GH action runners (#2976)
Latest runners release (20231127.1) somehow broke our tests. Connections to postgres somehow failing with strange error:
```
could not accept SSL connection: Socket operation on non-socket
```
2023-12-06 15:28:03 +01:00
Alexander KukushkinandGitHub 0e6a2ff3a9 Don't let replica restore initialize key when DCS was wiped (#2970)
It was happening from the branch where Patroni was supposed to be complain about converting standalone PG cluster to be governed by Patroni and exit.
2023-12-05 08:30:20 +01:00
Alexander Kukushkin 63ffb6320f Fix oversight of rebace 2023-12-01 11:35:20 +01:00
Alexander Kukushkin d7b4b4e8a9 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-12-01 10:20:21 +01:00
Alexander KukushkinandGitHub 6976939f09 Release/v3.2.1 (#2968)
- bump version
- bump pyright
- update release notes
2023-11-30 16:50:42 +01:00
WaynervandGitHub ef5f320602 Cache postgres --describe-config output results (#2967)
We don't expect GUCs list to change for the same major version and don't expect major version to change while Patroni is running.
2023-11-30 12:02:42 +01:00
Sophia RuanandGitHub 47cadc9f63 Fix the issue that REST API returns unknown after postgres restart (#2956)
Close #2955
2023-11-30 10:02:19 +01:00
Ali MehrajiandGitHub 5a77cbb087 Update: etcd flags in command in docker-compose.yml and docker-compose-citus.yml (#2966) 2023-11-30 09:45:07 +01:00
Alexander KukushkinandGitHub 92f4aa2ef9 Simplify methods related to replication slots in the Cluster class (#2958)
Instead of passing around names, specific tags, and Postgres version just pass Postgresql object and objects implementing Tags interface.

It should simplify implementation of #2842
2023-11-29 14:22:49 +01:00
Alexander KukushkinandGitHub 7c3ce78231 Fix Citus transaction rollback condition check (#2964)
It seems that sometimes we get an exact match, what makes behave tests to fail.
2023-11-29 08:44:35 +01:00
LaotreeandGitHub 76e19ecfe2 Update README.rst (#2965)
fix setting.rst link 404, from #2661
2023-11-29 08:43:07 +01:00
Alexander KukushkinandGitHub 9afaf6eb51 Don't pass around is_paused to sync_replication_slots (#2963)
Oversight of #2935
2023-11-28 08:37:22 +01:00
Konstantin DeminandGitHub 36e3dfbe41 update Dockerfiles (#2937)
- better cleanup for vim
- introduce dumb-init for patroni containers
2023-11-27 09:38:03 +01:00
zhjwpkuandGitHub bb804074f7 [doc]: fix typos (#2961) 2023-11-27 08:28:46 +01:00
zhjwpkuandGitHub ed9d4750f9 fix typo and add gitignore entries (#2959)
Split unrelated changes from #2940

Signed-off-by: Zhao Junwang <[email protected]>
2023-11-24 15:17:20 +01:00
Alexander KukushkinandGitHub 193c73f6b8 Make GlobalConfig really global (#2935)
1. extract `GlobalConfig` class to its own module
2. make the module instantiate the `GlobalConfig` object on load and replace sys.modules with the this instance
3. don't pass `GlobalConfig` object around, but use `patroni.global_config` module everywhere.
4. move `ignore_slots_matchers`, `max_timelines_history`,  and `permanent_slots` from `ClusterConfig` to `GlobalConfig`.
5. add `use_slots` property to global_config and remove duplicated code from `Cluster` and `Postgresql.ConfigHandler`.

Besides that improve readability of couple of checks in ha.py and formatting of `/config` key when saved from patronictl.
2023-11-24 09:26:05 +01:00
Alexander KukushkinandGitHub 91327f943c Factor out dynamic class finder/loader to a dedicated file (#2954)
It could be reused to do the same for MPP modules/classes.
Ref: #2940 and #2950
2023-11-23 17:04:23 +01:00
Ali MehrajiandGitHub ac6f6ae1c2 Add ETCDCTL_API=3 env to Dockerfiles and update docker/README.md (#2946) 2023-11-22 08:55:51 +01:00
Alexander KukushkinandGitHub 70b0991e6a Bump pyright to 1.1.336 (#2952)
and fix newly reported issues
2023-11-20 10:22:52 +01:00
Alexander KukushkinandGitHub 5dab735534 Compatibility with antient mock (#2951)
Just in case is someone still uses ubuntu 18.04
2023-11-15 11:25:46 +01:00
Alexander KukushkinandGitHub ecf158bce3 Get rid of pass_obj() in most of patronictl commands (#2945)
The `obj` could be easily obtained with the help of `click.get_current_context().obj`.

Introduced function `is_citus_cluster()` will simplify future refactoring to add support of other MPP databases.

In addition to that refactor ctl.py unit tests by moving most of mocks to the global scope.,
2023-11-14 13:44:54 +01:00
Alexander KukushkinandGitHub 1870dcd8f9 Fix bug with custom bootstrap (#2948)
Patroni was falsely applying `--command` argument.

Close https://github.com/zalando/patroni/issues/2947
2023-11-13 15:01:57 +01:00
Alexander KukushkinandGitHub 7370f70f13 Fix pg_rewind behavior with Postgres v16+ (#2944)
The error message format was changed in
https://github.com/postgres/postgres/commit/4ac30ba4f29d4b586b131404b0d514f16501272a, what caused `pg_rewind` being called by Patroni even when it was not necessary.
2023-11-10 09:23:45 +01:00
Alexander KukushkinandGitHub 1b96ae9c0a Fix Etcd v2 with Citus (#2943)
When deploying a new Citus cluster with Etcd v2 Patroni was failing to start with the following exception:
```python
2023-11-09 10:51:41,246 INFO: Selected new etcd server http://localhost:2379
Traceback (most recent call last):
  File "/home/akukushkin/git/patroni/./patroni.py", line 6, in <module>
    main()
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 343, in main
    return patroni_main(args.configfile)
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 237, in patroni_main
    abstract_main(Patroni, configfile)
  File "/home/akukushkin/git/patroni/patroni/daemon.py", line 172, in abstract_main
    controller = cls(config)
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 66, in __init__
    self.ensure_unique_name()
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 112, in ensure_unique_name
    cluster = self.dcs.get_cluster()
  File "/home/akukushkin/git/patroni/patroni/dcs/__init__.py", line 1654, in get_cluster
    cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
  File "/home/akukushkin/git/patroni/patroni/dcs/__init__.py", line 1638, in _get_citus_cluster
    cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
AttributeError: 'Cluster' object has no attribute 'pop'
```

It is broken since #2909.

In addition to that fix `_citus_cluster_loader()` interface by allowing it to return only dict obj.
2023-11-09 11:09:38 +01:00
Alexander KukushkinandGitHub 3ffd598a1c Do a real http request when performing name uniqueness check (#2942)
When running in containers it is possible that the traffic is routed using `docker-proxy`, which listens on the port and accepting incoming connections.

This commit effectively sticks to the original solution from #2878
2023-11-08 14:08:02 +01:00
Alexander KukushkinandGitHub 552e8643d9 Verify that replica nodes received checkpoint LSN on shutdown (#2939)
In case if archiving is enabled the `Postgresql.latest_checkpoint_location()` method returns LSN of the prev (SWITCH) record, which points to the beginning of the WAL file. It is done in order to make it possible to safely promote replica which recovers WAL files from the archive and wasn't streaming when the primary was stopped (primary doesn't archive this WAL file).

But, in certain cases using the LSN pointing to SWITCH record was causing unnecessary pg_rewind, if replica didn't managed to replay shutdown checkpoint record before it was promoted.

In order to mitigate the problem we need to check that replica received/replayed exactly the shutdown checkpoint LSN. But, at the same time we will still write LSN of the SWITCH record to the `/status` key when releasing the leader lock.
2023-11-07 11:05:54 +01:00
IsraelandGitHub 269b04be5d Add a contrib script for remote Barman recovery (#2931)
A contrib script, which can be used as a custom bootstrap method, or as a custom create replica method.

The script communicates with the pg-backup-api on the Barman node so Patroni is able to restore a Barman backup remotely.

The `--help` option of the script, along with the script docstring, should provide some context on how to use fill its parameters.

Patroni docs were updated accordingly to share examples about how to configure the script as a custom bootstrap method, or as a custom create replica method.

References: PAT-216.
2023-11-06 16:25:27 +01:00
Alexander KukushkinandGitHub 8adddb3467 Limit accepted values for --format argument (#2938)
It used to accept any arbitrary string

Close https://github.com/zalando/patroni/issues/2936
2023-11-03 13:02:39 +01:00
IsraelandGitHub d72f7cb259 Add a FAQ page to the docs (#2933)
This commit introduces a FAQ page to the docs. The idea is to get
most frequently asked questions answered before-hand, so the user
is able to get them answered quickly without going into detail in
the docs or having to go to Slack/GitHub to clarify questions.

---------
Signed-off-by: Israel Barth Rubio <[email protected]>
2023-11-01 14:02:04 +01:00
Aras MumcuyanandGitHub c3dce46830 Add ability to pass auth_data to zk client (#2932) 2023-10-30 11:46:36 +01:00
Alexander KukushkinandGitHub ce10e5fccc Release v3.2.0 (#2930)
- bump version
- bump pyright and apply fixes
- update release notes
2023-10-25 16:13:30 +02:00
IsraelandGitHub bb90feb393 Add support for additional parameters on custom bootstrap (#2927)
Previous to this commit, if a user would ever like to add parameters to the custom bootstrap script call, they would need to configure Patroni like this:

```
bootstrap:
  method: custom_method_name
  custom_method_name:
    command: /path/to/my/custom_script --arg1=value1 --arg2=value2 ...
```

This commit extends that so we achieve a similar behavior that is seen when using `create_replica_methods`, i.e., we also allow the following syntax:

```
bootstrap:
  method: custom_method_name
  custom_method_name:
    command: /path/to/my/custom_script
    arg1: value1
    arg2: value2
```

All keys in the mapping which are not recognized by Patroni, will be dealt with as if they were additional named arguments to be passed down to the `command` call.

References: PAT-218.
2023-10-25 15:01:08 +02:00
Alexander KukushkinandGitHub 3d527f5728 Improve formatting of generated config and validation of ints (#2928)
- order sections similar to sample configs
- add warnings and comments to `bootstrap.dcs` section.
- add `tags` and `log` sections.
- use discovered IPs in `postgresql.connect_address` and `postgresql.listen`
- set `wal_level` to `replica` for PostgreSQL 9.6+
- make unit tests pass with python 3.6
- improve config validator so it doesn't complain when some ints are strings in YAML file.
2023-10-25 14:23:57 +02:00
Polina BunginaandGitHub 6c06f5cc96 Add initial docs for patroni --validate/generate config (#2929)
For now it will sit in the section about the Patroni configuration. We can later move it to (or reference from) a new section where all the functionality of the `patroni` executable will be described.
2023-10-25 14:20:17 +02:00
Mark PekalaandGitHub f5ee67fa1c Feature: failover priority (#2780)
The priority is configured with `failover_priority` tag. Possible values are from `0` till infinity, where `0` means that the node will never become the leader, which is the same as `nofailover` tag set to `true`. As a result, in the configuration file one should set only one of `failover_priority` or `nofailover` tags.

The failover priority kicks in only when there are more than one node have the same receive/replay LSN and are ahead of other nodes in the cluster. In this case the node with higher value of `failover_priority` is preferred. If there is a node with higher values of receive/replay LSN, it will become the new leader even if it has lower value of `failover_priority` (except when priority is set to 0).

Close https://github.com/zalando/patroni/issues/2759
2023-10-24 12:22:48 +02:00
IsraelandGitHub 65030c56ee Add capability of specifying namespace through --dcs argument (#2926)
This commit changes the `patronictl` application in such a way its
`--dcs` argument is now able to receive a namespace.

Previous to this commit this was the format of that argument's value:
`DCS://HOST:PORT`.

From now on it accepts this format: `DCS://HORT:PORT/NAMESPACE`. As all
previous parts of the argument value, `NAMESPACE` is optional, and if
not given `patronictl` will fallback to the value from the configuration
file, if any, or to `service`.

This change is specifically useful when you are running a cluster in a
custom namespace, and from a machine where you don't have a configuration
file for Patroni or `patronictl`. It can avoid that you would have to
create a configuration file only with `namespace` filed in that case.

Issue reported by: Shaun Thomas <[email protected]>

Signed-off-by: Israel Barth Rubio <[email protected]>
2023-10-24 12:09:44 +02:00
d471f1156d Handle AuthOldRevision error (#2913)
The error is raised if Etcd is configured to use JWT auth tokens and when the user database in Etcd is updated, because the update invalidates all tokens.

If retries are requested - try to get a new new token and repeat the request. Repeat it in a loop until request is successfully executed or until `retry_timeout` is exhausted. This is the only way of solving a race condition, because between authentication and executing the request yet another modification of the user database in Etcd might happen.

In case if the request doesn't have to be immediately retried - set a flag that the next API request should perform the authentication first and let Patroni to naturally repeat the request on the next heartbeat loop.

Co-authored-by: Kenny Do <[email protected]>
Ref: https://github.com/zalando/patroni/pull/2911
2023-10-23 14:00:37 +02:00
Alexander KukushkinandGitHub 6d98944e73 Add warning to the sample config about bootstrap section (#2925)
often people are trying to change it and coming with the questions why it doesn't work.
2023-10-23 10:03:18 +02:00
zhjwpkuandGitHub 6cfd90401e get rid of stale comment of get_cluster (#2922)
PR #2909 remove the cache in Zookeeper implementation of DCS, so
the comment of get_cluster should be changed to 'Retrieve a fresh
view of DCS' since every implementation does so.

Signed-off-by: Zhao Junwang <[email protected]>
2023-10-23 08:30:13 +02:00
GuanqunYang193andGitHub ce187bec38 Remove user creation related docs (#2920)
* Remove user creation related docs
* remove template
2023-10-23 08:29:09 +02:00
Alexander KukushkinandGitHub c5fffb3c97 Further work on permanent physical slots (#2891)
- Fixed issues with has_permanent_slots() method. It didn't took into account the case of permanent physical slots for members, falsely concluding that there are no permanent slots.
- Write to the status key only LSNs for permanent slots (not just for slots that exist on the primary).
  - Include pg_current_wal_flush_lsn() to slots feedback, so that slots on standby nodes could be advanced
- Improved behave tests:
  - Verify that permanent slots are properly created on standby nodes
  - Verify that permanent slots are properly advanced, including DCS failsafe mode
  - Verify that only permanent slots are written to the `/status`
2023-10-23 08:24:28 +02:00
zhjwpkuandGitHub cb5f34b721 add some guide to run tests in different scopes (#2921)
Introduce ways to run tests in different scopes which should be helpful for beginners.
2023-10-23 08:17:53 +02:00
zhjwpkuandGitHub 260ab36f2e mock getaddrinfo in case test failure (#2918)
Close #2915
2023-10-17 19:53:19 +02:00
Alexander KukushkinandGitHub fc67ba73f0 Allow to specify psycopg* in extras and switch to build (#2907)
* remove check_psycopg() call from the setup.py, when installing from wheel it doesn't work anyway.
* call check_psycopg() function before process_arguments(), because the last one is trying to import psycopg and fails with the stacktrace, while the first one shows a nice human-readable error message.
* add psycopg2, psycopg2-binary, and psycopg3 extras, that will install psycopg2>=2.5.4, psycopg2-binary, or psycopg[binary]>=3.0.0 modules respectively.
* move check_psycopg() function to the __main__.py.
* introduce the new extra called `all`, it will allow to install all dependencies at once (except psycopg related).
* use the `build` module in order to create sdist bdist_wheel packages.
* update the documentation regarding psycopg and extras (dependencies).
2023-10-17 14:46:15 +02:00
GuanqunYang193andGitHub 60d8bc3a70 Add warning of removing user creation (#2893) 2023-10-17 13:04:59 +02:00
Alexander KukushkinandGitHub e513f7f127 Attempt to reduce flakiness for recovery behave test on K8s (#2917)
wait until Postgres is properly started after the first crash before changing `primary_start_timeout` and killing it once again.
2023-10-17 11:27:41 +02:00
Alexander KukushkinandGitHub aa3ebe0af8 Don't cache anything in Zookeeper implementation (#2909)
Cache creates a lot of problems and prevents implementing a feature of automatic retention of physical replication slots for members with configurable retention policy.

Just read the entire cluster from Zookeeper instead and use watchers only for the `/leader` and `/config` keys.
2023-10-17 08:56:31 +02:00
Alexander KukushkinandGitHub c96e35c807 Enable Citus behave tests for Postgres v16 (#2914)
and reduce flakiness
2023-10-16 16:05:27 +02:00
André LitfinandGitHub 88b35252c3 Update README.md to reflect changes in etcd v3 (#2912)
In etcdctl v3 the ls command isn't present anymore, it has to be changed to etcdctl get --keys-only --prefix
2023-10-16 15:18:25 +02:00
Alexander KukushkinandGitHub d93db20baa Set citus.local_hostname (#2903)
There are cases when Citus wants to have a connection to the local postgres. By default it uses `localhost` for that, which is not alwasy available. To solve it we will set `citus.local_hostname` GUC to custom value, which is the same as Patroni uses to connect to Postgres.
2023-10-16 10:21:50 +02:00
Alexander KukushkinandGitHub 42976df86f Make it easier to debug callbacks (#2902)
1. Introduce DEBUG logs for callbacks
2. Configure log format in behave tests to include filename, line, and method name that triggered the callback and enable DEBUG logs for `patroni.postgresql.callback_executor` module.

P.S. unfortunately it works only starting from python 3.8, but it should be good enough for debug purpose because 3.7 is already EOL.
2023-10-16 08:55:07 +02:00
zhjwpkuandGitHub 6f4c2fe132 %s/iter_dcs_modules/iter_dcs_classes/g (#2905) 2023-10-11 13:17:18 +02:00
Chris BandyandGitHub 588df5da05 Refine the documentation about custom_conf (#2901)
some back icks in this section needed to be balanced.
2023-10-11 08:41:11 +02:00
Polina BunginaandGitHub fb367cd73e Change cb checks in standby cluster behave test (#2899)
fix and extend callback content checks
2023-10-10 13:49:52 +02:00
Alexander KukushkinandGitHub 535dc631ec Bugfix: standby cluster switchover (#2900)
1.  Enforce `_load_cluster()` after acquisition for the leader lock in ZooKeeper. Sometimes the notification from ZooKeeper was arriving too late and Patroni wasn't setting the `role=standby_leader`.

2. The `_get_node_to_follow()` method was falsely assuming that we still own the leader lock and returning the remote node instead of the new standby leader. While not a big issue per se, because the next HA loop usually fixes it, such behavior was causing flakiness of behave tests with Postgres 12 and older, where restart is required to update `primary_conninfo` GUC.
2023-10-10 12:21:19 +02:00
Alexander KukushkinandGitHub 9b8c40a6e1 Start thread that will handle SIGCHLD for on_reload callback (#2898)
Close #2897
2023-10-10 09:54:24 +02:00
Alexander KukushkinandGitHub e19a8730ea Take IP from the pod if kubernetes.pod_ip is missing (#2895)
It used to work before #2652

Besides that fix a couple of more problems:
- make sure `_patch_or_create()` method isn't instantiating the `k8s_client.V1ConfigMap` object instead of `k8s_client.V1Endpoints` for non leader endpoints. The only reason it worked is that the JSON serialization for both object types is the same and doesn't include the object type name.
- `attempt_to_acquire_leader()` should immediately put the IP address of the primary to the leader endpoint. It didn't happen because of the oversight in the https://github.com/zalando/patroni/pull/1820.
2023-10-09 10:43:43 +02:00
IsraelandGitHub 28a604983b Enhancement to tox behave tests (#2889)
* Add `etcd3` as a DCS option for behave tests in `tox.ini`

Currently behave tests run through `tox` accept only `etcd` as a DCS.

This commit adds the option of using `etcd3` too.

* Add JSON report to `tox` behave tests

This commit adds a JSON report when running behave tests through
`tox`.

That makes it easier to parse the results.

---------

Signed-off-by: Israel Barth Rubio <[email protected]>
2023-10-06 10:48:55 +02:00
Polina BunginaandGitHub efacc6c16b Ignore synchronous_mode setting in a standby cluster (#2896)
is_synchronous_mode() should always return False in standby clusters
2023-10-06 10:21:37 +02:00
Alexander KukushkinandGitHub 9283ebda64 Enforce loop_wait/retry_timeout/ttl rule (#2869)
* hard-code minimal possible values
* make adjustments if values are lower or if the rule is violated and show warnings
* update documentation
2023-10-04 11:44:57 +02:00
IsraelandGitHub a329a9d320 Add a documentation page for patronictl (#2874)
This PR introduces a documentation page for `patronictl` application.

We adopted a top-down approach when writing this document. We start by describing the outer most parts, and then keep writing new sections that specialize the knowledge.

We basically added a section called `patronictl` to the left menu. Inside that section we created a page with this structure:

- `patronictl`: describes what it is
    - `Configuraiton`: how to configure `patronictl`
    - `Usage`: how to use the CLI. Inside this section, there are subsections for each of the subcommands exposed by `patronictl`, and each of them are described using the following subsubsections:
        - `Synopsis`: syntax of the command and its positional and optional arguments
        - `Description`: a description of what the command does
        - `Parameters`: a detailed description of the arguments and how to use them
        - `Examples`: one or more examples of execution of the command

References: PAT-200.
2023-10-04 11:43:38 +02:00
Alexander KukushkinandGitHub f77073c8e1 Speed up dcs failsafe behave tests (#2890)
- get rid from sleeps
- reduce retry_timeout
- avoid graceful Patroni shut down while DCS is "paused", just kill
  Patroni and after that gracefully stop postgres
- don't try to delete Pod when Patroni is killed. If K8s API is paused it takes ages

The run time on my laptop is reduced from 2m to 1m28s.
2023-09-28 10:44:11 +02:00
Polina BunginaandGitHub aaac6f6fb0 Don't fail if pg_hba/pg_ident contain comment lines (#2888)
yaml parser interprets such lines as null and stores it as None into the
array of the parsed values, which can not be handled by write() function
and crashes the whole bootstrap process.
Even though it is not the proper value, it won't hurt if we just ignore it instead of failing completely.
2023-09-27 15:57:09 +02:00
Polina BunginaandGitHub 27915984b4 Add contrib requirement for tests, small docs refactoring (#2887) 2023-09-27 12:19:58 +02:00
Polina BunginaandGitHub 220cacd95f Don't call socket functions from tests (#2886)
We used to call `socket` module's functions from the config_generator tests
to later compare with the output produced by --generate-config. That
however sometimes ends up with the whole test module failure if gethostname()
returned None.
Also includes a little code deduplication (NO_VALUE_MSG imported directly from the config_generator module)
and removes debug maxDiff option
2023-09-26 15:52:00 +02:00
Alexander KukushkinandGitHub a3b3e1bc1c Release v3.1.2 (#2885)
- bump version
- update release notes
2023-09-26 12:30:27 +02:00
Alexander KukushkinandGitHub c855b0bff9 Detect and solve inconsistency between /sync and actual sync nodes (#2877)
Patroni is changing `synchronous_standby_names` and the `/sync` key in a very specific order, first we add nodes to `synchronous_standby_names` and only after, when they are recognized as synchronous they are added to the `/sync` key. When removing nodes the order is different: they are first removed from the `/sync` key and only after that from the `synchronous_standby_names`.

As a result Patroni expects that either actual synchronous nodes will match with the nodes listed in the `/sync` key or that new candidates to synchronous nodes will not match with nodes listed in the `/sync` key. In case if `synchronous_standby_names` was removed from the `postgresql.conf`, manually, or due the the bug (#2876), the state becomes inconsistent because of the wrong order of updates.

To solve inconsistent state we introduce additional checks and will update the `/sync` key with actual names of synchronous nodes (usually empty set).
2023-09-26 11:14:20 +02:00
Alexander KukushkinandGitHub 4c1c804cfd Read GUC's values when joining running Postgres (#2876)
If restarted in pause Patroni was discarding `synchronous_standby_names` from `postgresql.conf` because in the internal cache this values was set to `None`. As a result synchronous replication transitioned to a broken state, with no synchronous replicas according to the `synchronous_standby_names` and Patroni not selecting/setting the new synchronous replicas (another bug).

To solve the problem of broken initial state and to avoid similar issues with other GUC's we will read GUC's value if Patroni is joining running Postgres.
2023-09-26 10:40:51 +02:00
Alexander KukushkinandGitHub 48514db84b Take into account current role when deciding on removal of member ZNode (#2884)
Patroni doesn't watch on all changes of member keys in order to not create too much load on ZooKeeper, but only subscribes to changes (ZNodes added or deleted) in the `/member` directory. Therefore when some important fields in the value are updated we remove and recreate ZNode in order to notify the leader or other members.

The leader should remove the member key only when the `checkpoint_after_promote` value is changed and replicas when the `state` is changed to/from `running`.

We don't care about the `version` field, because Patroni version can't be changed without restart, what will case ZooKeeper `session_id` to change it anyway.

This fix hopefully will reduce failures of behave tests on GH Actions.
2023-09-26 09:12:31 +02:00
Alexander KukushkinandGitHub 2bd821a768 Bugfix for GUC's values with units (#2883)
Despite being validated by `IntValidator` some GUC's couldn't be casted directly to `int` because they include suffix. Example: `128MB`.

Close https://github.com/zalando/patroni/issues/2879
2023-09-26 08:31:55 +02:00
Alexander KukushkinandGitHub fe16c3610e Silence annoying warnings when checking for node uniqueness (#2878)
WARNING messages are produced by `urllib3` if Patroni is quickly restarted.
Instead we will check that the node is listen on a given port. This fact is actually enough to detect names clashes, while HTTP request could raise an exception is a few other cases, what might case false negatives.

Close https://github.com/zalando/patroni/issues/2881
2023-09-26 08:29:35 +02:00
Alexander KukushkinandGitHub bc15813de0 Permanent physical slots on standby nodes (#2852)
Create permanent physical replication slots on standby nodes and use `pg_replication_slot_advance()` function to move them forward.

The `restart_lsn` is advanced based on values stored in the `/status` key by the primary node.

When slot is created on a replica it could be ahead the same slot on the primary and therefore there is some period of time when it doesn't protect WAL files from being recycled.
2023-09-20 16:50:37 +02:00
Alexander KukushkinandGitHub 18d9cb1124 Stick with sphinx_rtd_theme (#2873)
by default they are using something else
2023-09-20 14:59:15 +02:00
Alexander KukushkinandGitHub 66bdb1ae12 Release v3.1.1 (#2872)
* Bump version
* Update release notes
* Update contributing guidelines and tox.ini (include v16)
* Enable tests for `REL*` branches
2023-09-20 12:00:18 +02:00
Alexander KukushkinandGitHub 28b9d3d2d9 Bump pyright version (#2871)
and fix all reported issues.

We aren't sticking to the latest version this time because it has [a bug](https://github.com/microsoft/pyright/issues/5968).
2023-09-19 10:32:35 +02:00
Polina BunginaandGitHub 25ceb68257 Fix k8s dockerfiles (#2870)
- Allow pip to modify an EXTERNALLY-MANAGED Python installation by passing --break-system-packages
- Build Citus for arm64
- Don't use PG_MAJOR argument
2023-09-18 15:30:35 +02:00
Alexander KukushkinandGitHub 5a504e67c1 Don't rely on pg_stat_wal_receiver when deciding on pg_rewind (#2863)
As was reported by @ants on Slack it could happen that `received_tli` is ahead of replayed timeline, therefore we should stop using it when deciding on pg_rewind if postgres is running and use only `IDENTIFY_SYSTEM` via replication connection.
2023-09-15 11:32:49 +02:00
Alexander KukushkinandGitHub 75dbe4ff96 Update supported Postgres versions (#2857) 2023-09-14 19:36:26 +02:00
Polina BunginaandGitHub 71863cedcb Always store CMDLINE_OPTIONS config values as int (#2861) 2023-09-14 18:34:45 +02:00
IsraelandGitHub 728abfcc37 Fix bug in patronictl query command (#2859)
Previous to this commit `patronictl query` was working only if `-r` argument was provided to the command. Otherwise it would face issues:

* If neither `-r` nor `-m` were provided:

```
 PGPASSWORD=zalando patronictl -c postgres0.yml query -U postgres -c "SHOW PORT"
2023-09-12 17:45:38	No connection to role=None is available
```

* If only `-m` was provided:

```
$ PGPASSWORD=zalando patronictl -c postgres0.yml query -U postgres -c "SHOW PORT" -m postgresql0
2023-09-12 17:46:15	No connection to member postgresql0 is available
```

This issue was a regression introduced by `4c3e0b9382820524239d2aa4d6b95379ef1291db` through PR #2687.

Through that PR we decided to move the common logic used to check mutually exclusiveness of `--role` and `--member` arguments to `get_any_member` function.

However, previous to that change `role` variable would assume the default value of `any` in `query` method, before `get_any_member` was called, which was not the case after the change.

This commit fixes that issue by adding a handler in `get_cursor` function to `role=None`. As `role` defaulting to `any` is handled in a sub-call to `get_any_member`, we are apparently safe in `get_cursor` to return the cursor if `role=None`.

Unit tests were updated accordingly.

References: PAT-204.
2023-09-14 15:27:23 +02:00
Alexander KukushkinandGitHub 238b8db91e Introduce Status class (#2853)
It represents the `/status` key in DCS and makes it easier to introduce new values stored in the `/status` key without need to refactor all DCS implementations.
2023-09-14 14:40:44 +02:00
Polina BunginaandGitHub b31a4d55c9 Ensure strict failover/switchover definition difference (#2784)
- Don't set leader in failover key from patronictl failover
- Show warning and execute switchover if leader option is provided for patronictl failover command
- Be more precise in the log messages
- Allow to failover to an async candidate in sync mode
- Check if candidate is the same as the leader specified in api
- Fix and extend some tests
- Add documentation
2023-09-12 08:51:17 +02:00
IsraelandGitHub 3c24c33e59 Document how to change Postgres settings that touch shared memory (#2843)
Some special handling is required when changing either of these settings in a Postgres cluster that has standby nodes:

* `max_connections`
* `max_prepared_transactions`
* `max_locks_per_transaction`
* `max_wal_senders`
* `max_worker_processes`

If one attempts to decrease `max_connections` dynamic setting and restart all nodes at the same time (primary and standbys), Patroni will refuse to apply the new value on the standbys and require the user to restart it again later, once replication catches up.

That behavior is correct, but it is not documented.

This commit adds information to documentation about that behavior and why it's required.

References: PAT-166.
2023-09-11 19:25:01 +02:00
Matt BakerandGitHub 83a060fc15 Extend documentation with package installation and upgrade process (#2854) 2023-09-11 19:02:13 +02:00
Alexander Kukushkin 84042f3297 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-09-11 15:54:24 +02:00
a2ceff1517 Generate documentation of private members through sphinx docs (#2831)
* Generate documentation of private members through sphinx docs

With this commit we make sphinx build API docs for the following
things, which were missing up to this point:

* `__init__` method of classes;
* "private" members (properties, functions, methods, attributes, etc.,
  which name starts with an underscore);
* members that are missing a docstring, so we can still reference
them with links in the documentation.

The third point can be removed later, if we wish, when we reach a
point where everything has proper docstrings in the Patroni code base.

* Fix documentation problems found after enabling private methods in sphinx

* `:cvar:` is not a valid domain role. Replaced with `:attr:`.
* documentation for `consul.base.Consul.__init__` has a single backtick
quoted string which is interpreted as a reference which cannot be found.
Therefore, the docstring has been copied as a block quote.
* various list spacing problems and indentation problems.
* code blocks added where indentation is interpreted incorrectly
* literal string quoting issues.

---------

Signed-off-by: Israel Barth Rubio <[email protected]>
Co-authored-by: Matt Baker <[email protected]>
2023-09-11 15:41:34 +02:00
Alexander KukushkinandGitHub 19f20ec2eb Refactor replication slots handling (#2851)
1. make _get_members_slots() method return data in the same format as _get_permanent_slots() method
2. move conflicting name handling from get_replication_slots() to _get_members_slots() method
3. enrich structure returned by get_replication_slots() with the LSN of permanent logical slots reported by primary
4. use the added information in the SlotsHandler instead of fetching it from the Cluster.slots
5. bugfix: don't try to advance logical slot that doesn't match required configuration
2023-09-07 12:56:07 +02:00
Alexander KukushkinandGitHub 30f0f132e8 Don't start stopped postgres in pause (#2848)
Due to a race condition Patroni was falsely assuming that the standby should be restarted because some recovery parameters (primary_conninfo or similar) were changed.

Close https://github.com/zalando/patroni/issues/2834
2023-09-06 08:57:56 +02:00
Alexander KukushkinandGitHub 941e883dde Override write_leader_optime method in K8s implementation (#2850)
It is being called when postgres is already shut down cleanly but there are no healthy replicas to take it over.

Close https://github.com/zalando/patroni/issues/2837
Close https://github.com/zalando/patroni/pull/2838
2023-09-05 07:41:45 +02:00
Polina BunginaandGitHub 89a162e000 Return system id to the ctl list title (#2840) 2023-09-05 07:27:34 +02:00
Alexander KukushkinandGitHub 0ab5b49757 Introduce a dedicated postgres connection for REST API (#2833)
Sharing a single connection between REST API and the main thread (doing heartbeats) was working mostly fine, except when Postgres becomes so slow that REST API queries start blocking the main loop.

If the dedicated REST API connection isn't available we use the heartbeat connection as a fallback.
2023-09-05 07:26:44 +02:00
SKandGitHub 80a03a4892 Enreach some endpoints with the scope and name (#2846)
- monitoring endpoints - added `name` to the `patroni`, next to the `scope` and `version`
- metrics endpoint - added name to labels
2023-09-05 07:24:17 +02:00
Matt BakerandGitHub d2603402ea Debian docker image pip error (#2849)
* Use virtualenv to install tox in behave Dockerfile

Upstream change in postgres docker image uses debian restriction on
installing system-wide non-debian python packages. Debian doesn't
provide a tox>=4, so we need to install with pip.

* Exclude all output directories generated using `tox-wrapper.sh`

The `tox-wrapper.sh` script created by `features/Dockerfile` creates
directories like features/output-tox-pg14-docker-behave-etcd-lin-973719674/

* Reduce footprint of tox behave docker image
2023-09-04 21:24:26 +02:00
Alexander KukushkinandGitHub 6b7f914da7 Fix bug with kubernetes.standby_leader_label_value (#2832)
When running with the leader lock Patroni was just setting the `role` label to `master` and effectively `kubernetes.standby_leader_label_value` feature never worked.

Now it is fixed, but in order to not introduce breaking changes we just update default value of the `standby_leader_label_value` to the `master`.
2023-09-04 10:03:37 +02:00
IsraelandGitHub 03107e6d8b patronictl --help was showing ctl function's docstring (#2845)
`patronictl` is implemented using `click` module, and that module uses the functions' docstrings for creating a helper text.

As a consequence the docstring for `ctl` function was being shown to the user, which doesn't make sense.

This PR fixes that issue by adding a user-friendly description to be shown on `patronictl --help`. We use a `\f` to tell `click` when to stop capturing text to show in the helper.

Note that `patronictl` commands are implemented using `@ctl.command` decorator, and we always provide them with `help` argument. That said, none of the subcommands are affected by the aforementioned issue, only the entry point of the CLI.

References: PAT-201.
2023-09-04 09:27:46 +02:00
Alexander KukushkinandGitHub 77dba39585 Pin version of sphinx-github-style (#2847)
1.0.3 removed support of `top_level` configuration parameter and builds
now are failing.

Besides that remove redundant pyyaml from requirements.docs.txt
2023-09-04 09:00:24 +02:00
Alexander Kukushkin a49c534803 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-24 16:32:19 +02:00
Alexander KukushkinandGitHub 89d794facc Introduce connection pool (#2829)
Make it hold connection kwargs for local connections and all `NamedConnection` objects use them automatically.

Also get rid of redundant `ConfigHandler.local_connect_kwargs`.

On top of that we will introduce a dedicated connection for the REST API thread.
2023-08-24 16:13:22 +02:00
Alexander Kukushkin c4f95200bc Move _node_hash to PgDistNode and call it as_tuple.
And more work on docstrings
2023-08-24 12:09:06 +02:00
Alexander Kukushkin e96b77c7aa Address review feedback 2023-08-24 09:59:18 +02:00
Alexander Kukushkin b3b3493f3d Rename group to groupid 2023-08-23 16:08:47 +02:00
Alexander Kukushkin c3bba15ce1 Address review feedback 2023-08-23 09:38:46 +02:00
Alexander Kukushkin d5063bd3d7 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-23 09:36:34 +02:00
Alexander KukushkinandGitHub 3333e78500 Factor out tags handling into a dedicated class (#2823)
The same (almost) logic was used in three different places:
1. `Patroni` class
2. `Member` class
3. `_MemberStatus` class

Now they all inherit newly intoduced `Tags` class.
2023-08-21 17:03:14 +02:00
Matt BakerandGitHub 0ab4bc9d27 Exclude sphinx build files from git (#2828) 2023-08-21 16:23:04 +02:00
Polina BunginaandGitHub 13cfe0af36 Pin sphinx_rtd_theme to >1 (#2825)
Earlier versions are incompatible with sphinx>7
2023-08-21 07:55:02 +02:00
Polina BunginaandGitHub 7319d12026 Remove accidentally added .DS_Store (#2826)
And extend .gitignore
2023-08-21 07:50:45 +02:00
Polina BunginaandGitHub 2ec9834c60 Update api examples (#2824)
* Add failsafe_mode_is_active to /patroni and /metrics
* Add patroni_primary to /metrics
* Add examples showing that failsafe_mode_is_active and cluster_unlocked
  are only shown for /patroni when the value is "true"
* Update /patroni and /config examples
2023-08-18 16:13:13 +02:00
Alexander KukushkinandGitHub 2be64e5131 Don't return logical slots for standby cluster (#2816)
Cluster.get_replication_slots() didn't take into account that there can not be logical replication slots in a standby cluster replicas. It was only skipping logical slots for the standby_leader, but replicas were expecting that they will have to copy them over.

Also on replicas in a standby cluster these logical slots were falsely added to the `_replication_slots` dict.
2023-08-18 13:36:32 +02:00
Alexander Kukushkin 36bb077964 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-18 11:10:35 +02:00
Alexander KukushkinandGitHub 93be10a655 Remove Python 2 install instructions from docs/README (#2822)
docs/README.rst mainly duplicates README.rst and also should be changed. Besides that remove test/coverage badges.

followup on #2821
2023-08-17 16:17:34 +02:00
Alexander KukushkinandGitHub 366829e379 Refactor Connection class (#2815)
1. stop using the same cursor all the time, it creates problems when not carefully used from different threads.
2. introduce query() method in the Connection class and make it return a result set when it is possible.
3. refactor most of the code that is relying (directly or indirectly) on the Connection object to use the query() method as much as possible.

This refactoring helps with reducing code complexity and will help with future introduction of a separate database connection for the REST API thread. The last one will help to improve reliability when system is under significant stress when simple monitoring queries are taking seconds to execute and the REST API starts blocking the main thread.
2023-08-17 15:42:11 +02:00
Alexander Kukushkin 74ed88611f Please sphinx 2023-08-17 13:27:59 +02:00
Jelte FennemaandGitHub 899cad1c0f Remove Python 2 install instructions from README (#2821) 2023-08-17 13:18:37 +02:00
Alexander Kukushkin e54b88534c Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-17 13:16:01 +02:00
IsraelandGitHub a4ac4963d1 Fix IntValidator regarding validation of value 0 (#2818)
Previous to this commit `IntValidator` would always consider the value `0` invalid, even if in the allowed range.

The problem was that `parse_int` was returning `0` in the following line:

```python
value = parse_int(value, self.base_unit) or ""
```

However the `or ""` was evaluating to an empty string.

As `parse_int` returns either an `int` if able to parse, or `None` otherwise, the `isinstance(value, int)` is enough to error out when not a valid `int`.

Closes #2817
2023-08-17 12:55:42 +02:00
704d36815a Explicitly enable synchronous mode (#2820)
Close https://github.com/zalando/patroni/issues/2819

Co-authored-by: Polina Bungina <[email protected]>
2023-08-17 12:33:15 +02:00
IsraelandGitHub 4138d0b830 Add docstrings to patroni.config (#2708)
Besides adding docstrings to `patroni.config`, a few side changes
have been applied:

* Reference `config_file` property instead of internal attribute
`_config_file` in method `_load_config_file`;
* Have `_AUTH_ALLOWED_PARAMETERS[:2]` as default value of `params`
argument in method `_get_auth` instead of using
`params or _AUTH_ALLOWED_PARAMETERS[:2]` in the body;
* Use `len(PATRONI_ENV_PREFIX)` instead of a hard-coded `8` when
removing the prefix from environment variable names;
* Fix documentation of `wal_log_hints` setting. The previous docs
mentioned it was a dynamic setting that could be changed. However
it is managed by Patroni, which forces `on` value.

References: PAT-123.
2023-08-17 11:19:49 +02:00
Matt BakerandGitHub b7ea511511 Generate API docs from code with sphinx autodoc (#2699)
Expanding on the addition of docstrings in code, this adds python module API docs to sphinx documentation.

A developer can preview what this might look like by running this locally:

```
tox -m docs
```

The option `-W` is added to the tox env so that warning messages are considered errors.

Adds doc generation using the above method to the test GitHub workflow to catch documentation problems on PRs.

Some docstrings have been reformatted and fixed to satisfy errors generated with the above setup.
2023-08-17 10:27:33 +02:00
IsraelandGitHub badf1da183 Add docstrings to patroni.__main__ (#2701)
References: PAT-117.
2023-08-15 09:05:25 +02:00
Alexander KukushkinandGitHub 6a75b1591b Use pg_current_wal_flush_lsn() starting from 9.6 (#2813)
Due to historical reasons (not available before 9.6) we used `pg_current_wal_lsn()`/`pg_current_xlog_location()` functions to get current WAL LSN on the primary. But, this LSN is not necessarily synced to disk, and could be lost if the primary node crashed.
2023-08-15 09:01:37 +02:00
Matt BakerandGitHub 82d2ef4878 Make docs more clear on changes to the bootstrap.dcs section of YAML config (#2811)
It seems that a common pitfall for new users of Patroni is that the `bootstrap.dcs` section is only used to initialize the configuration in DCS. This moves the comment about this to an info block so it is more visible to the reader.
2023-08-11 10:31:31 +02:00
Mark PekalaandGitHub b83f1c0f44 [Refactor] Rename _is_leader to _leader_expiry (#2807) 2023-08-11 10:30:20 +02:00
Alexander KukushkinandGitHub 9209a5a133 Refactor delete_leader interface (#2810)
similar to https://github.com/zalando/patroni/pull/2690, but it helps mostly Consul implementation.
2023-08-11 10:19:29 +02:00
Polina BunginaandGitHub 3734ecc851 Implement generate-config (#2786)
New patroni.py option that allows to

* generate patroni.yml configuration file with the values from a running cluster
* generate a sample patroni.yml configuration file
2023-08-09 17:46:53 +02:00
Alexander KukushkinandGitHub 713244975c Silence useless warnings in patronictl (#2808)
Close https://github.com/zalando/patroni/issues/2805
2023-08-09 14:48:18 +02:00
Alexander KukushkinandGitHub efaba9f183 Rename Postgresql.is_leader() to is_primary() (#2809)
It'll help to avoid confusion with the Ha.is_leader() method.
2023-08-09 14:47:53 +02:00
f24db395c6 Refactor is_failover_possible() (#2804)
* Refactor is_failover_possible()

Move all the members filtering inside the function.

* Remove check_synchronous parameter
* Add sync_mode_is_active() method and user it everywhere where it is appropriate
* Reduce nesting

---------

Co-authored-by: Alexander Kukushkin <[email protected]>
2023-08-08 11:50:02 +02:00
Matt BakerandGitHub 9dd177e5c9 Add docstrings to patroni.postgresql.slots.py (#2778)
Also, made some small code changes to satisfy formatting and pylint.
2023-08-08 08:54:55 +02:00
Matt BakerandGitHub eb100fd586 Add docs to patroni.dcs.__init__.py (#2777)
Also, made some small code changes to satisfy formatting and pylint.
2023-08-08 08:49:12 +02:00
ChenChangAoandGitHub a74985f41d reset failsafe state when promote (#2803)
consider the scenario(enable failsafe_mode):

0. node1(primary) - node2(replica)
1. stop all etcd nodes; wait ttl seconds; start all etcd nodes; (node2's failsafe will contain the info about node1)
2. switchover to node2; (node2's failsafe still contain the info about node1)
3. stop all etcd nodes; wait ttl seconds; start all etcd nodes;
4. node2 will demote because it consider node1 as primary

Resetting failsafe state when running as a primary fixes the issue.
2023-08-04 13:55:56 +02:00
Alexander KukushkinandGitHub da9aaf6cdf Mock request() method when running tests (#2802)
1. Unit tests should not really try accessing any resources.
2. Not doing so results in significant execution time of unit tests on Windows

In addition to that perform a request with timeout 3s. Usually this is more than enough to figure out whether resource is accessible.

Followup on #2724
2023-08-04 07:37:49 +02:00
Alexander Kukushkin a6e05b240c Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-02 10:12:33 +02:00
Alexander Kukushkin 9ba40f0a25 Register Citus secondaries in pg_dist_node
1. All nodes with role == 'replica' and state == 'running' are
   are registered. In case is state isn't running the node is removed.
2. In case of failover/switchover we always first update the primary
3. When switching to a registered secondary we call citus_update_node()
   three times: rename primary to primary-demoted, put the primary name
   to a promoted secondary row and put the promoted secondary name to
   the primary row

State transitions are produced by the transition() method. First of all
the method makes sure that the actual primary is registered in the
metadata. In case if for a given group the primary didn't change, the
method registers new secondaries and removes secondaries that are gone.
It prefers to use citus_update_node() UDF to replace gone secondaries
with added.

Communication protocol between primary nodes remains the same and all
old features work without any changes.
2023-07-13 15:14:07 +02:00
134 changed files with 13897 additions and 3857 deletions
+3 -3
View File
@@ -45,8 +45,8 @@ def install_packages(what):
packages['exhibitor'] = packages['zookeeper']
packages = packages.get(what, [])
ver = versions.get(what)
if float(ver) == 15:
packages += ['postgresql-{0}-citus-12.0'.format(ver)]
if float(ver) >= 15:
packages += ['postgresql-{0}-citus-12.1'.format(ver)]
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
@@ -110,7 +110,7 @@ def install_etcd():
def install_postgres():
version = os.environ.get('PGVERSION', '15.1-1')
version = os.environ.get('PGVERSION', '16.1-1')
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
if platform == 'osx':
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
+1 -1
View File
@@ -1 +1 @@
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '14', 'kubernetes': '15'}
+4 -1
View File
@@ -24,8 +24,11 @@ jobs:
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Install Python packaging build frontend
run: python -m pip install build
- name: Build a binary wheel and a source tarball
run: python setup.py sdist bdist_wheel
run: python -m build
- name: Publish distribution to Test PyPI
if: github.event_name == 'push'
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
unbuffer = ['timeout', '900', 'unbuffer']
else:
if sys.platform == 'darwin':
version = os.environ.get('PGVERSION', '15.1-1')
version = os.environ.get('PGVERSION', '16.1-1')
path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
unbuffer = ['unbuffer']
else:
+2 -2
View File
@@ -85,7 +85,7 @@ jobs:
env:
DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.4.23
PGVERSION: 15.1-1 # for windows and macos
PGVERSION: 16.1-1 # for windows and macos
strategy:
fail-fast: false
matrix:
@@ -174,7 +174,7 @@ jobs:
- uses: jakebailey/pyright-action@v1
with:
version: 1.1.326
version: 1.1.356
docs:
runs-on: ubuntu-latest
+8 -2
View File
@@ -27,14 +27,15 @@ lib64
pip-log.txt
# Unit test / coverage reports
.coverage
.coverage*
.tox
nosetests.xml
coverage.xml
htmlcov
junit.xml
features/output
features/output*
dummy
result.json
# Translations
*.mo
@@ -48,10 +49,12 @@ pgpass
scm-source.json
# Sphinx-generated documentation
docs/_build/
docs/build/
docs/source/_static/
docs/source/_templates/
docs/modules/
docs/pdf/
# Pycharm IDE
.idea/
@@ -64,3 +67,6 @@ venv*/
# Default test data directory
data/
# macOS
**/.DS_Store
+7 -4
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=15
ARG PG_MAJOR=16
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -94,9 +94,9 @@ RUN set -ex \
/usr/share/locale/??_?? \
/usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim80/doc \
/usr/share/vim/vim80/lang \
/usr/share/vim/vim80/tutor \
/usr/share/vim/vim*/doc \
/usr/share/vim/vim*/lang \
/usr/share/vim/vim*/tutor \
# /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \
&& find /var/log -type f -exec truncate --size 0 {} \; \
@@ -125,6 +125,8 @@ RUN if [ "$COMPRESS" = "true" ]; then \
&& /bin/busybox sh -c "(find $save_dirs -not -type d && cat /exclude /exclude && echo exclude) | sort | uniq -u | xargs /bin/busybox rm" \
&& /bin/busybox --install -s \
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
else \
/bin/busybox --install -s; \
fi
FROM scratch
@@ -143,6 +145,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
ENV ETCDCTL_API=3
COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
+6 -5
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=15
ARG PG_MAJOR=16
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -40,7 +40,7 @@ RUN set -ex \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
fi \
\
# Cleanup all locales but en_US.UTF-8
@@ -113,9 +113,9 @@ RUN set -ex \
/usr/share/locale/??_?? \
/usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim80/doc \
/usr/share/vim/vim80/lang \
/usr/share/vim/vim80/tutor \
/usr/share/vim/vim*/doc \
/usr/share/vim/vim*/lang \
/usr/share/vim/vim*/tutor \
# /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \
&& find /var/log -type f -exec truncate --size 0 {} \; \
@@ -164,6 +164,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
ENV ETCDCTL_API=3
COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
+14 -22
View File
@@ -74,27 +74,11 @@ There are a few options available:
::
sudo apt-get install python-psycopg2 # install python2 psycopg2 module on Debian/Ubuntu
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu
sudo yum install python-psycopg2 # install python2 psycopg2 on RedHat/Fedora/CentOS
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
2. Install psycopg2 from the binary package
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the list of dependencies when installing Patroni with pip (see below).
::
pip install psycopg2-binary
3. Install psycopg2 from source
::
pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
::
pip install psycopg[binary]
**General installation for pip**
@@ -120,12 +104,20 @@ raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto3` in order to use AWS callbacks
all
all of the above (except psycopg family)
psycopg3
`psycopg[binary]>=3.0.0` module
psycopg2
`psycopg2>=2.5.4` module
psycopg2-binary
`psycopg2-binary` module
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
::
pip install patroni[etcd,aws]
pip install patroni[psycopg3,etcd3,aws]
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
@@ -159,7 +151,7 @@ run:
YAML Configuration
==================
Go `here <https://github.com/zalando/patroni/blob/master/docs/SETTINGS.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
Go `here <https://github.com/zalando/patroni/blob/master/docs/dynamic_configuration.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
=========================
Environment Configuration
+3 -5
View File
@@ -19,7 +19,6 @@ services:
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ]
environment:
ETCDCTL_API: 3
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
@@ -28,19 +27,19 @@ services:
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1
hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
etcd2:
<<: *etcd
container_name: demo-etcd2
hostname: etcd2
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
etcd3:
<<: *etcd
container_name: demo-etcd3
hostname: etcd3
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
haproxy:
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
@@ -53,7 +52,6 @@ services:
- "5001:5001" # Load-balancing across workers primaries
command: haproxy
environment: &haproxy_env
ETCDCTL_API: 3
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
PATRONI_SCOPE: demo
+3 -3
View File
@@ -25,19 +25,19 @@ services:
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1
hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
etcd2:
<<: *etcd
container_name: demo-etcd2
hostname: etcd2
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
etcd3:
<<: *etcd
container_name: demo-etcd3
hostname: etcd3
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
haproxy:
image: ${PATRONI_TEST_IMAGE:-patroni}
+168 -168
View File
@@ -19,102 +19,97 @@ The haproxy listens on ports 5000 (connects to the primary) and 5001 (does load-
Example session:
$ docker-compose up -d
Creating demo-haproxy ...
Creating demo-patroni2 ...
Creating demo-patroni1 ...
Creating demo-patroni3 ...
Creating demo-etcd2 ...
Creating demo-etcd1 ...
Creating demo-etcd3 ...
Creating demo-haproxy
Creating demo-patroni2
Creating demo-patroni1
Creating demo-patroni3
Creating demo-etcd1
Creating demo-etcd2
Creating demo-etcd2 ... done
$ docker compose up -d
✔ Network patroni_demo Created
✔ Container demo-etcd1 Started
✔ Container demo-haproxy Started
✔ Container demo-patroni1 Started
✔ Container demo-patroni2 Started
✔ Container demo-patroni3 Started
✔ Container demo-etcd2 Started
✔ Container demo-etcd3 Started
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2
e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1
83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3
922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3
14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2
110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1
5af5e6e36028 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a37bcec56726 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd3
034ab73868a8 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni2
03837736f710 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni3
22815c3d85b3 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd2
814b4304d132 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy
6375b0ba2d0a patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni1
aef8bf3ee91f patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd1
$ docker logs demo-patroni1
2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul
2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379
2019-02-20 08:19:35,140 INFO: Lock owner: None; I am patroni1
2019-02-20 08:19:35,174 INFO: trying to bootstrap a new cluster
2023-11-21 09:04:33,547 INFO: Selected new etcd server http://172.29.0.3:2379
2023-11-21 09:04:33,605 INFO: Lock owner: None; I am patroni1
2023-11-21 09:04:33,693 INFO: trying to bootstrap a new cluster
...
2019-02-20 08:19:39,310 INFO: postmaster pid=37
2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432
2019-02-20 08:19:39.321 UTC [37] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2019-02-20 08:19:39.353 UTC [39] LOG: database system was shut down at 2019-02-20 08:19:36 UTC
2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up
localhost:5432 - rejecting connections
2019-02-20 08:19:39.369 UTC [37] LOG: database system is ready to accept connections
2023-11-21 09:04:34.920 UTC [43] LOG: starting PostgreSQL 15.5 (Debian 15.5-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
2023-11-21 09:04:34.921 UTC [43] LOG: listening on IPv4 address "0.0.0.0", port 5432
2023-11-21 09:04:34,922 INFO: postmaster pid=43
2023-11-21 09:04:34.922 UTC [43] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2023-11-21 09:04:34.925 UTC [47] LOG: database system was shut down at 2023-11-21 09:04:34 UTC
2023-11-21 09:04:34.928 UTC [43] LOG: database system is ready to accept connections
localhost:5432 - accepting connections
2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster
2019-02-20 08:19:39,408 INFO: running post_bootstrap
2019-02-20 08:19:39,432 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
2019-02-20 08:19:39,515 INFO: initialized a new cluster
2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1
2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1
2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock
2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1
localhost:5432 - accepting connections
2023-11-21 09:04:34,938 INFO: establishing a new patroni heartbeat connection to postgres
2023-11-21 09:04:34,992 INFO: running post_bootstrap
2023-11-21 09:04:35,004 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
2023-11-21 09:04:35,009 WARNING: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'
2023-11-21 09:04:35,189 INFO: initialized a new cluster
2023-11-21 09:04:35,328 INFO: no action. I am (patroni1), the leader with the lock
2023-11-21 09:04:43,824 INFO: establishing a new patroni restapi connection to postgres
2023-11-21 09:04:45,322 INFO: no action. I am (patroni1), the leader with the lock
2023-11-21 09:04:55,320 INFO: no action. I am (patroni1), the leader with the lock
...
$ docker exec -ti demo-patroni1 bash
postgres@patroni1:~$ patronictl list
+---------+----------+------------+--------+---------+----+-----------+
| Cluster | Member | Host | Role | State | TL | Lag in MB |
+---------+----------+------------+--------+---------+----+-----------+
| demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 |
| demo | patroni2 | 172.22.0.7 | | running | 1 | 0 |
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
+---------+----------+------------+--------+---------+----+-----------+
+ Cluster: demo (7303838734793224214) --------+----+-----------+
| Member | Host | Role | State | TL | Lag in MB |
+----------+------------+---------+-----------+----+-----------+
| patroni1 | 172.29.0.2 | Leader | running | 1 | |
| patroni2 | 172.29.0.6 | Replica | streaming | 1 | 0 |
| patroni3 | 172.29.0.5 | Replica | streaming | 1 | 0 |
+----------+------------+---------+-----------+----+-----------+
postgres@patroni1:~$ etcdctl ls --recursive --sort -p /service/demo
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
/service/demo/config
/service/demo/initialize
/service/demo/leader
/service/demo/members/
/service/demo/members/patroni1
/service/demo/members/patroni2
/service/demo/members/patroni3
/service/demo/optime/
/service/demo/optime/leader
/service/demo/status
postgres@patroni1:~$ etcdctl member list
1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false
8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true
b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false
2bf3e2ceda5d5960, started, etcd2, http://etcd2:2380, http://172.29.0.3:2379
55b3264e129c7005, started, etcd3, http://etcd3:2380, http://172.29.0.7:2379
acce7233f8ec127e, started, etcd1, http://etcd1:2380, http://172.29.0.8:2379
postgres@patroni1:~$ exit
$ docker exec -ti demo-haproxy bash
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W
Password: postgres
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
psql (15.5 (Debian 15.5-1.pgdg120+1))
Type "help" for help.
localhost/postgres=# select pg_is_in_recovery();
postgres=# SELECT pg_is_in_recovery();
pg_is_in_recovery
───────────────────
f
(1 row)
localhost/postgres=# \q
postgres=# \q
$postgres@haproxy:~ psql -h localhost -p 5001 -U postgres -W
postgres@haproxy:~$ psql -h localhost -p 5001 -U postgres -W
Password: postgres
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
psql (15.5 (Debian 15.5-1.pgdg120+1))
Type "help" for help.
localhost/postgres=# select pg_is_in_recovery();
postgres=# SELECT pg_is_in_recovery();
pg_is_in_recovery
───────────────────
t
@@ -127,81 +122,86 @@ The haproxy listens on ports 5000 (connects to the coordinator primary) and 5001
Example session:
$ docker-compose -f docker-compose-citus.yml up -d
Creating demo-work2-1 ... done
Creating demo-work1-1 ... done
Creating demo-etcd2 ... done
Creating demo-etcd1 ... done
Creating demo-coord3 ... done
Creating demo-etcd3 ... done
Creating demo-coord1 ... done
Creating demo-haproxy ... done
Creating demo-work2-2 ... done
Creating demo-coord2 ... done
Creating demo-work1-2 ... done
$ docker compose -f docker-compose-citus.yml up -d
✔ Network patroni_demo Created
✔ Container demo-coord2 Started
✔ Container demo-work2-2 Started
✔ Container demo-etcd1 Started
✔ Container demo-haproxy Started
✔ Container demo-work1-1 Started
✔ Container demo-work2-1 Started
✔ Container demo-work1-2 Started
✔ Container demo-coord1 Started
✔ Container demo-etcd3 Started
✔ Container demo-coord3 Started
✔ Container demo-etcd2 Started
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
852d8885a612 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-coord3
cdd692f947ab patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work1-2
9f4e340b36da patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-etcd3
d69c129a960a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd1
c5849689b8cd patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord1
c9d72bd6217d patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-1
24b1b43efa05 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord2
cb0cc2b4ca0a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-2
9796c6b8aad5 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 5 seconds demo-work1-1
8baccd74dcae patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd2
353ec62a0187 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
79c95492fac9 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd3
77eb82d0f0c1 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-1
03dacd7267ef patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd1
db9206c66f85 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd2
9a0fef7b7dd4 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-2
f06b031d99dc patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-2
f7c58545f314 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord2
383f9e7e188a patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-1
f02e96dcc9d6 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord3
6945834b7056 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord1
b96ca42f785d patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy
$ docker logs demo-coord1
2023-01-05 15:09:31,295 INFO: Selected new etcd server http://172.27.0.4:2379
2023-01-05 15:09:31,388 INFO: Lock owner: None; I am coord1
2023-01-05 15:09:31,501 INFO: trying to bootstrap a new cluster
2023-11-21 09:36:14,293 INFO: Selected new etcd server http://172.30.0.4:2379
2023-11-21 09:36:14,390 INFO: Lock owner: None; I am coord1
2023-11-21 09:36:14,478 INFO: trying to bootstrap a new cluster
...
2023-01-05 15:09:45,096 INFO: postmaster pid=39
2023-11-21 09:36:16,475 INFO: postmaster pid=52
localhost:5432 - no response
2023-01-05 15:09:45.137 UTC [39] LOG: starting PostgreSQL 15.1 (Debian 15.1-1.pgdg110+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 10.2.1-6) 10.2.1 20210110, 64-bit
2023-01-05 15:09:45.137 UTC [39] LOG: listening on IPv4 address "0.0.0.0", port 5432
2023-01-05 15:09:45.152 UTC [39] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2023-01-05 15:09:45.177 UTC [43] LOG: database system was shut down at 2023-01-05 15:09:32 UTC
2023-01-05 15:09:45.193 UTC [39] LOG: database system is ready to accept connections
2023-11-21 09:36:16.495 UTC [52] LOG: starting PostgreSQL 15.5 (Debian 15.5-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
2023-11-21 09:36:16.495 UTC [52] LOG: listening on IPv4 address "0.0.0.0", port 5432
2023-11-21 09:36:16.496 UTC [52] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2023-11-21 09:36:16.498 UTC [56] LOG: database system was shut down at 2023-11-21 09:36:15 UTC
2023-11-21 09:36:16.501 UTC [52] LOG: database system is ready to accept connections
localhost:5432 - accepting connections
localhost:5432 - accepting connections
2023-01-05 15:09:46,139 INFO: establishing a new patroni connection to the postgres cluster
2023-01-05 15:09:46,208 INFO: running post_bootstrap
2023-01-05 15:09:47.209 UTC [55] LOG: starting maintenance daemon on database 16386 user 10
2023-01-05 15:09:47.209 UTC [55] CONTEXT: Citus maintenance daemon for database 16386 user 10
2023-01-05 15:09:47,215 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
2023-01-05 15:09:47.446 UTC [41] LOG: checkpoint starting: immediate force wait
2023-01-05 15:09:47,466 INFO: initialized a new cluster
2023-01-05 15:09:47,594 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
2023-01-05 15:09:47,594 INFO: establishing a new patroni connection to the postgres cluster
2023-01-05 15:09:47,467 INFO: Lock owner: coord1; I am coord1
2023-01-05 15:09:47,613 DEBUG: query(SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default'), ('172.27.0.6', 5432))
2023-01-05 15:09:47,924 INFO: no action. I am (coord1), the leader with the lock
2023-01-05 15:09:51.282 UTC [41] LOG: checkpoint complete: wrote 1086 buffers (53.0%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.029 s, sync=3.746 s, total=3.837 s; sync files=280, longest=0.028 s, average=0.014 s; distance=8965 kB, estimate=8965 kB
2023-01-05 15:09:51.283 UTC [41] LOG: checkpoint starting: immediate force wait
2023-01-05 15:09:51.495 UTC [41] LOG: checkpoint complete: wrote 18 buffers (0.9%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.044 s, sync=0.091 s, total=0.212 s; sync files=15, longest=0.015 s, average=0.007 s; distance=67 kB, estimate=8076 kB
2023-01-05 15:09:57,467 INFO: Lock owner: coord1; I am coord1
2023-01-05 15:09:57,569 INFO: Assigning synchronous standby status to ['coord3']
2023-11-21 09:36:17,509 INFO: establishing a new patroni heartbeat connection to postgres
2023-11-21 09:36:17,569 INFO: running post_bootstrap
2023-11-21 09:36:17,593 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
2023-11-21 09:36:17,783 INFO: establishing a new patroni restapi connection to postgres
2023-11-21 09:36:17,969 WARNING: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'
2023-11-21 09:36:17.969 UTC [70] LOG: starting maintenance daemon on database 16386 user 10
2023-11-21 09:36:17.969 UTC [70] CONTEXT: Citus maintenance daemon for database 16386 user 10
2023-11-21 09:36:18.159 UTC [54] LOG: checkpoint starting: immediate force wait
2023-11-21 09:36:18,162 INFO: initialized a new cluster
2023-11-21 09:36:18,164 INFO: Lock owner: coord1; I am coord1
2023-11-21 09:36:18,297 INFO: Enabled synchronous replication
2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=0,host=172.30.0.3,port=5432,event=after_promote)
2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=1,host=172.30.0.7,port=5432,event=after_promote)
2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=2,host=172.30.0.8,port=5432,event=after_promote)
2023-11-21 09:36:18,299 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
2023-11-21 09:36:18,299 INFO: establishing a new patroni citus connection to postgres
2023-11-21 09:36:18,323 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.30.0.7', 5432, 1))
2023-11-21 09:36:18,361 INFO: no action. I am (coord1), the leader with the lock
2023-11-21 09:36:18,393 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.30.0.8', 5432, 2))
2023-11-21 09:36:28,164 INFO: Lock owner: coord1; I am coord1
2023-11-21 09:36:28,251 INFO: Assigning synchronous standby status to ['coord3']
server signaled
2023-01-05 15:09:57.574 UTC [39] LOG: received SIGHUP, reloading configuration files
2023-01-05 15:09:57.580 UTC [39] LOG: parameter "synchronous_standby_names" changed to "coord3"
2023-01-05 15:09:59,637 INFO: Synchronous standby status assigned to ['coord3']
2023-01-05 15:09:59,638 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.2', 5432, 1))
2023-01-05 15:09:59.690 UTC [67] LOG: standby "coord3" is now a synchronous standby with priority 1
2023-01-05 15:09:59.690 UTC [67] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
2023-01-05 15:09:59,694 INFO: no action. I am (coord1), the leader with the lock
2023-01-05 15:09:59,704 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.8', 5432, 2))
2023-01-05 15:10:07,625 INFO: no action. I am (coord1), the leader with the lock
2023-01-05 15:10:17,579 INFO: no action. I am (coord1), the leader with the lock
2023-11-21 09:36:28.435 UTC [52] LOG: received SIGHUP, reloading configuration files
2023-11-21 09:36:28.436 UTC [52] LOG: parameter "synchronous_standby_names" changed to "coord3"
2023-11-21 09:36:28.641 UTC [83] LOG: standby "coord3" is now a synchronous standby with priority 1
2023-11-21 09:36:28.641 UTC [83] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
2023-11-21 09:36:30,582 INFO: Synchronous standby status assigned to ['coord3']
2023-11-21 09:36:30,626 INFO: no action. I am (coord1), the leader with the lock
2023-11-21 09:36:38,250 INFO: no action. I am (coord1), the leader with the lock
...
$ docker exec -ti demo-haproxy bash
postgres@haproxy:~$ etcdctl member list
1bab629f01fa9065, started, etcd3, http://etcd3:2380, http://172.27.0.10:2379
8ecb6af518d241cc, started, etcd2, http://etcd2:2380, http://172.27.0.4:2379
b2e169fcb8a34028, started, etcd1, http://etcd1:2380, http://172.27.0.7:2379
2b28411e74c0c281, started, etcd3, http://etcd3:2380, http://172.30.0.4:2379
6c70137d27cfa6c1, started, etcd2, http://etcd2:2380, http://172.30.0.5:2379
a28f9a70ebf21304, started, etcd1, http://etcd1:2380, http://172.30.0.6:2379
postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo
/service/demo/0/config
@@ -229,7 +229,7 @@ Example session:
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
Password for user postgres: postgres
psql (15.1 (Debian 15.1-1.pgdg110+1))
psql (15.5 (Debian 15.5-1.pgdg120+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
@@ -240,67 +240,67 @@ Example session:
(1 row)
citus=# table pg_dist_node;
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
--------+---------+------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
3 | 2 | 172.27.0.8 | 5432 | default | t | t | primary | default | t | t
1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f
2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
3 | 2 | 172.30.0.8 | 5432 | default | t | t | primary | default | t | t
(3 rows)
citus=# \q
postgres@haproxy:~$ patronictl list
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+---------+----+-----------+
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
| 2 | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
| 2 | work2-2 | 172.27.0.8 | Leader | running | 1 | |
+-------+---------+-------------+--------------+---------+----+-----------+
+ Citus cluster: demo ----------+--------------+-----------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+-----------+----+-----------+
| 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
| 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
| 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
| 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
| 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
| 2 | work2-1 | 172.30.0.8 | Leader | running | 1 | |
| 2 | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
+-------+---------+-------------+--------------+-----------+----+-----------+
postgres@haproxy:~$ patronictl switchover --group 2 --force
Current cluster topology
+ Citus cluster: demo (group: 2, 7185185529556963355) +-----------+
| Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+--------------+---------+----+-----------+
| work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
| work2-2 | 172.27.0.8 | Leader | running | 1 | |
+---------+-------------+--------------+---------+----+-----------+
2023-01-05 15:29:29.54204 Successfully switched over to "work2-1"
+ Citus cluster: demo (group: 2, 7185185529556963355) -------+
+ Citus cluster: demo (group: 2, 7303846899271086103) --+-----------+
| Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+--------------+-----------+----+-----------+
| work2-1 | 172.30.0.8 | Leader | running | 1 | |
| work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
+---------+-------------+--------------+-----------+----+-----------+
2023-11-21 09:44:15.83849 Successfully switched over to "work2-2"
+ Citus cluster: demo (group: 2, 7303846899271086103) -------+
| Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+---------+---------+----+-----------+
| work2-1 | 172.27.0.11 | Leader | running | 1 | |
| work2-2 | 172.27.0.8 | Replica | stopped | | unknown |
| work2-1 | 172.30.0.8 | Replica | stopped | | unknown |
| work2-2 | 172.30.0.11 | Leader | running | 1 | |
+---------+-------------+---------+---------+----+-----------+
postgres@haproxy:~$ patronictl list
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+---------+----+-----------+
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
| 2 | work2-1 | 172.27.0.11 | Leader | running | 2 | |
| 2 | work2-2 | 172.27.0.8 | Sync Standby | running | 2 | 0 |
+-------+---------+-------------+--------------+---------+----+-----------+
+ Citus cluster: demo ----------+--------------+-----------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+-----------+----+-----------+
| 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
| 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
| 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
| 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
| 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
| 2 | work2-1 | 172.30.0.8 | Sync Standby | streaming | 2 | 0 |
| 2 | work2-2 | 172.30.0.11 | Leader | running | 2 | |
+-------+---------+-------------+--------------+-----------+----+-----------+
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
Password for user postgres: postgres
psql (15.1 (Debian 15.1-1.pgdg110+1))
psql (15.5 (Debian 15.5-1.pgdg120+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
citus=# table pg_dist_node;
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
--------+---------+-------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
3 | 2 | 172.27.0.11 | 5432 | default | t | t | primary | default | t | t
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f
3 | 2 | 172.30.0.11 | 5432 | default | t | t | primary | default | t | t
2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
(3 rows)
+3 -1
View File
@@ -13,6 +13,8 @@ readonly PATRONI_NAMESPACE="${PATRONI_NAMESPACE%/}"
DOCKER_IP=$(hostname --ip-address)
readonly DOCKER_IP
export DUMB_INIT_SETSID=0
case "$1" in
haproxy)
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
@@ -72,4 +74,4 @@ export PATRONI_SUPERUSER_SSLKEY="${PATRONI_SUPERUSER_SSLKEY:-$PGSSLKEY}"
export PATRONI_SUPERUSER_SSLCERT="${PATRONI_SUPERUSER_SSLCERT:-$PGSSLCERT}"
export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}"
exec python3 /patroni.py postgres0.yml
exec dumb-init python3 /patroni.py postgres0.yml
BIN
View File
Binary file not shown.
+13 -13
View File
@@ -14,25 +14,24 @@ Global/Universal
Log
---
- **PATRONI\_LOG\_TYPE**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
- **PATRONI\_LOG\_LEVEL**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **PATRONI\_LOG\_TRACEBACK\_LEVEL**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **PATRONI\_LOG\_LEVEL=DEBUG**.
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. If the log type is **plain**, the log format should be a string.
Refer to `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
Default value is **%(asctime)s %(levelname)s: %(message)s**
- **PATRONI\_LOG\_DATEFORMAT**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **PATRONI\_LOG\_STATIC\_FIELDS**: add additional fields to the log. This option is only available when the log type is set to **json**. Example ``PATRONI_LOG_STATIC_FIELDS="{app: patroni}"``
- **PATRONI\_LOG\_MAX\_QUEUE\_SIZE**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **PATRONI\_LOG\_DIR**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this env variable, the application will retain 4 25MB logs by default. You can tune those retention values with `PATRONI_LOG_FILE_NUM` and `PATRONI_LOG_FILE_SIZE` (see below).
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
- **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"``
Bootstrap configuration
-----------------------
It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables:
- **PATRONI\_<username>\_PASSWORD='<password>'**
- **PATRONI\_<username>\_OPTIONS='list,of,options'**
Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases.
Citus
-----
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
@@ -94,6 +93,7 @@ ZooKeeper
- **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]}``.
- **PATRONI\_ZOOKEEPER\_AUTH\_DATA**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -209,10 +209,10 @@ REST API
CTL
---
- **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format.
- **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format.
- **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile.
+1 -81
View File
@@ -25,83 +25,7 @@ We report new releases information :ref:`here <releases>`.
Technical Requirements/Installation
-----------------------------------
**Pre-requirements for Mac OS**
To install requirements on a Mac, run the following:
::
brew install postgresql etcd haproxy libyaml python
.. _psycopg2_install_options:
**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.
There are a few options available:
1. Use the package manager from your distro
::
sudo apt-get install python-psycopg2 # install python2 psycopg2 module on Debian/Ubuntu
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu
sudo yum install python-psycopg2 # install python2 psycopg2 on RedHat/Fedora/CentOS
2. Install psycopg2 from the binary package
::
pip install psycopg2-binary
3. Install psycopg2 from source
::
pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
::
pip install psycopg[binary]>=3.0.0
**General installation for pip**
Patroni can be installed with pip:
::
pip install patroni[dependencies]
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 Distributed Configuration Store (DCS)
consul
`python-consul` module in order to use Consul as DCS
zookeeper
`kazoo` module in order to use Zookeeper as DCS
exhibitor
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
kubernetes
`kubernetes` module in order to use Kubernetes as DCS in Patroni
raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto3` in order to use AWS callbacks
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
::
pip install patroni[etcd,aws]
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
independently of Patroni.
Go :ref:`here <installation>` for guidance on installing and upgrading Patroni on various platforms.
.. _running_configuring:
@@ -165,10 +89,6 @@ Applications Should Not Use Superusers
When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
:target: https://travis-ci.org/zalando/patroni
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
:target: https://coveralls.io/r/zalando/patroni?branch=master
Testing Your HA Solution
--------------------------------------
+15 -11
View File
@@ -38,14 +38,18 @@ After that you just need to start Patroni and it will handle the rest:
2. If ``max_prepared_transactions`` isn't explicitly set in the global
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
automatically set it to ``2*max_connections``.
3. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
4. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
3. The ``citus.local_hostname`` GUC value will be adjusted from ``localhost`` to the
value that Patroni is using in order to connect to the local PostgreSQL
instance. The value sometimes should be different from the ``localhost``
because PostgreSQL might be not listening on it.
4. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
5. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
table to allow cross-node communication. Don't forget to update them if
later you decide to change superuser username/password/sslcert/sslkey!
5. The coordinator primary node will automatically discover worker primary
6. The coordinator primary node will automatically discover worker primary
nodes and add them to the ``pg_dist_node`` table using the
``citus_add_node()`` function.
6. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
7. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
on the coordinator or worker clusters occurs.
patronictl
@@ -57,7 +61,7 @@ clusters that are just logically groupped together using the
PostgreSQL. Therefore in most cases it is not possible to manage them as a
single entity.
It results in two major differences in ``patronictl`` behaviour when
It results in two major differences in :ref:`patronictl` behaviour when
``patroni.yaml`` has the ``citus`` section comparing with the usual:
1. The ``list`` and the ``topology`` by default output all members of the Citus
@@ -65,12 +69,12 @@ It results in two major differences in ``patronictl`` behaviour when
which Citus group they belong to.
2. For all ``patronictl`` commands the new option is introduced, named
``--group``. For some commands the default value for the group might be
taken from the ``patroni.yaml``. For example, ``patronictl pause`` will
taken from the ``patroni.yaml``. For example, :ref:`patronictl_pause` will
enable the maintenance mode by default for the ``group`` that is set in the
``citus`` section, but for example for ``patronictl switchover`` or
``patronictl remove`` the group must be explicitly specified.
``citus`` section, but for example for :ref:`patronictl_switchover` or
:ref:`patronictl_remove` the group must be explicitly specified.
An example of ``patronictl list`` output for the Citus cluster::
An example of :ref:`patronictl_list` output for the Citus cluster::
postgres@coord1:~$ patronictl list demo
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
@@ -115,7 +119,7 @@ the coordinator for the shards hosted on a worker node. The switchover then
happens while the traffic is kept on the coordinator, and resumes as soon as a
new primary worker node is ready to accept read-write queries.
An example of ``patronictl switchover`` on the worker cluster::
An example of :ref:`patronictl_switchover` on the worker cluster::
postgres@coord1:~$ patronictl switchover demo
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
@@ -343,7 +347,7 @@ Citus upgrades and PostgreSQL major upgrades
First, please read about upgrading Citus version in the `documentation`__.
There is one minor change in the process. When executing upgrade, you have to
use ``patronictl restart`` instead of ``systemctl restart`` to restart
use :ref:`patronictl_restart` instead of ``systemctl restart`` to restart
PostgreSQL.
__ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html
+16
View File
@@ -54,6 +54,13 @@ apidoc_output_dir = 'modules'
apidoc_excluded_paths = excludes
apidoc_separate_modules = True
# Include autodoc for all members, including private ones and the ones that are missing a docstring.
autodoc_default_options = {
"members": True,
"undoc-members": True,
"private-members": True,
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -280,6 +287,14 @@ def doctree_read(app, doctree):
toc_tree_node['entries'].remove(e)
def autodoc_skip(app, what, name, obj, would_skip, options):
"""Include autodoc of ``__init__`` methods, which are skipped by default."""
if name == "__init__":
return False
return would_skip
# 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):
@@ -292,3 +307,4 @@ def setup(app):
app.connect('builder-inited', builder_inited)
app.connect('env-get-outdated', env_get_outdated)
app.connect('doctree-read', doctree_read)
app.connect("autodoc-skip-member", autodoc_skip)
+25 -17
View File
@@ -3,21 +3,29 @@
Contributing guidelines
=======================
Wanna contribute to Patroni? Yay - here is how!
.. _chatting:
Chatting
--------
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
If you have a question, looking for an interactive troubleshooting help or want to chat with other Patroni users, join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
.. _reporting_bugs:
Reporting bugs
--------------
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
Also please double check if the issue already exists in our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
Running tests
-------------
Requirements for running behave tests:
1. PostgreSQL packages need to be installed.
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
#. PostgreSQL packages including `contrib <https://www.postgresql.org/docs/current/contrib.html>`__ modules need to be installed.
#. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
#. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
Install dependencies:
@@ -39,6 +47,13 @@ After you have all dependencies installed, you can run the various test suites:
# Run the pytest suite in tests/:
python setup.py test
# Moreover, you may want to run tests in different scopes for debugging purposes,
# the -s option include print output during test execution.
# Tests in pytest typically follow the pattern: FILEPATH::CLASSNAME::TESTNAME.
pytest -s tests/test_api.py
pytest -s tests/test_api.py::TestRestApiHandler
pytest -s tests/test_api.py::TestRestApiHandler::test_do_GET
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
DCS=raft python -m behave
@@ -143,12 +158,12 @@ If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op
Behave tests
^^^^^^^^^^^^
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 16 and then run all
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
Postgres or to a specific feature set or steps.
To specify the version of postgres include the full name of the dependent image build env that you want and then the
behave env name. For instance if you want Postgres 15 use:
behave env name. For instance if you want Postgres 14 use:
.. code-block:: bash
@@ -163,19 +178,12 @@ the watchdog behave feature test scenario with all versions of Postgres.
Of course you can combine the two.
Reporting issues
----------------
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
Contributing a pull request
---------------------------
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
2) Do a fork, develop and test your code changes.
3) Include documentation
4) Submit a pull request.
#. Fork the repository, develop and test your code changes.
#. Reflect changes in the user documentation.
#. Submit a pull request with a clear description of the changes objective. Link an existing issue if necessary.
You'll get feedback about your pull request as soon as possible.
+1 -1
View File
@@ -60,4 +60,4 @@ F.A.Q.
- How to enable the Failsafe Mode?
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or ``patronictl edit-config -s failsafe_mode=true``
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or :ref:`patronictl edit-config -s failsafe_mode=true <patronictl_edit_config_parameters>`
+38 -7
View File
@@ -6,11 +6,20 @@ Dynamic Configuration Settings
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
In order to change the dynamic configuration you can use either :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10, minimum possible value: 1
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30, minimum possible value: 20
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10, minimum possible value: 3
.. warning::
when changing values of **loop_wait**, **retry_timeout**, or **ttl** you have to follow the rule:
.. code-block:: python
loop_wait + 2 * retry_timeout <= ttl
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
@@ -46,9 +55,9 @@ In order to change the dynamic configuration you can use either ``patronictl edi
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. With PostgreSQL 11 onwards permanent physical slots are created on all nodes and their position is advanced every **loop_wait** seconds. For PostgreSQL versions older than 11 permanent physical replication slots are maintained only on the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent replication slots requires **postgresql.use_slots** to be set to ``true``. If there are permanent logical replication slots defined Patroni will automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni.
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current node it will not be created on this node. If you add a permanent physical replication slot which name matches the name of a Patroni member, Patroni will ensure that the slot that was created is not removed even if the corresponding member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when you want replication slots used by members to persist during temporary failures or when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS, when the slot is no longer required, due to its effect on normal functioning of Patroni.
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
- **database**: the database name where logical slots should be created.
@@ -80,4 +89,26 @@ Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
plugin: test_decoding
- name: ignored_physical_slot_name
type: physical
...
...
Note: if cluster topology is static (fixed number of nodes that never change their names) you can configure permanent physical replication slots with names corresponding to names of nodes to avoid recycling of WAL files while replica is temporary down:
.. code:: YAML
slots:
node_name1:
type: physical
node_name2:
type: physical
node_name3:
type: physical
...
.. warning::
Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster.
An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them.
.. warning::
Setting ``nostream`` tag on standby disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas if any.
+4 -4
View File
@@ -36,18 +36,18 @@ You can find below an overview of steps for converting an existing Postgres clus
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
#. Create a YAML configuration file for Patroni.
#. Create a YAML configuration file for Patroni. You can use :ref:`Patroni configuration generation and validation tooling <validate_generate_config>` for that.
* **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings <yaml_configuration>` for details.
#. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance.
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through ``patronictl restart cluster-name member-name`` command. For minimal downtime you might want to split this step into:
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through :ref:`patronictl restart cluster-name member-name <patronictl_restart_parameters>` command. For minimal downtime you might want to split this step into:
#. Immediate restart of the standby nodes.
#. Scheduled restart of the primary node within a maintenance window.
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through ``patronictl edit-config cluster-name member-name`` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through :ref:`patronictl edit-config cluster-name member-name <patronictl_edit_config_parameters>` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
.. code-block:: sql
@@ -73,7 +73,7 @@ The only possible way to do a major upgrade currently is:
#. Stop Patroni
#. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
#. Update patroni.yml
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running :ref:`patronictl remove cluster-name <patronictl_remove_parameters>` . It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
#. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
#. Start Patroni on the primary node.
#. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
+329
View File
@@ -0,0 +1,329 @@
.. _faq:
FAQ
===
In this section you will find answers for the most frequently asked questions about Patroni.
Each sub-section attempts to focus on different kinds of questions.
We hope that this helps you to clarify most of your questions.
If you still have further concerns or find yourself facing an unexpected issue, please refer to :ref:`chatting` and :ref:`reporting_bugs` for instructions on how to get help or report issues.
Comparison with other HA solutions
----------------------------------
Why does Patroni require a separate cluster of DCS nodes while other solutions like ``repmgr`` do not?
There are different ways of implementing HA solutions, each of them with their pros and cons.
Software like ``repmgr`` performs communication among the nodes to decide when actions should be taken.
Patroni on the other hand relies on the state stored in the DCS. The DCS acts as a source of truth for Patroni to decide what it should do.
While having a separate DCS cluster can make you bloat your architecture, this approach also makes it less likely for split-brain scenarios to happen in your Postgres cluster.
What is the difference between Patroni and other HA solutions in regards to Postgres management?
Patroni does not just manage the high availability of the Postgres cluster but also manages Postgres itself.
If Postgres nodes do not exist yet, it takes care of bootstrapping the primary and the standby nodes, and also manages Postgres configuration of the nodes. If the Postgres nodes already exist, Patroni will take over management of the cluster.
Besides the above, Patroni also has self-healing capabilities. In other words, if a primary node fails, Patroni will not only fail over to a replica, but also attempt to rejoin the former primary as a replica of the new primary. Similarly, if a replica fails, Patroni will attempt to rejoin that replica.
That is way we call Patroni as a "template for HA solutions". It goes further than just managing physical replication: it manages Postgres as a whole.
DCS
---
Can I use the same ``etcd`` cluster to store data from two or more Patroni clusters?
Yes, you can!
Information about a Patroni cluster is stored in the DCS under a path prefixed with the ``namespace`` and ``scope`` Patroni settings.
As long as you do not have conflicting namespace and scope across different Patroni clusters, you should be able to use the same DCS cluster to store information from multiple Patroni clusters.
What occurs if I attempt to use the same combination of ``namespace`` and ``scope`` for different Patroni clusters that point to the same DCS cluster?
The second Patroni cluster that attempts to use the same ``namespace`` and ``scope`` will not be able to manage Postgres because it will find information related with that same combination in the DCS, but with an incompatible Postgres system identifier.
The mismatch on the system identifier causes Patroni to abort the management of the second cluster, as it assumes that refers to a different cluster and that the user has misconfigured Patroni.
Make sure to use different ``namespace`` / ``scope`` when dealing with different Patroni clusters that share the same DCS cluster.
What occurs if I lose my DCS cluster?
The DCS is used to store basically status and the dynamic configuration of the Patroni cluster.
They very first consequence is that all the Patroni clusters that rely on that DCS will go to read-only mode -- unless :ref:`dcs_failsafe_mode` is enabled.
What should I do if I lose my DCS cluster?
There are three possible outcomes upon losing your DCS cluster:
1. The DCS cluster is fully recovered: this requires no action from the Patroni side. Once the DCS cluster is recovered, Patroni should be able to recover too;
2. The DCS cluster is re-created in place, and the endpoints remain the same. No changes are required on the Patroni side;
3. A new DCS cluster is created with different endpoints. You will need to update the DCS endpoints in the Patroni configuration of each Patroni node.
If you face scenario ``2.`` or ``3.`` Patroni will take care of creating the status information again based on the current status of the cluster, and recreate the dynamic configuration on the DCS based on a backup file named ``patroni.dynamic.json`` which is stored inside the Postgres data directory of each member of the Patroni cluster.
What occurs if I lose majority in my DCS cluster?
The DCS will become unresponsive, which will cause Patroni to demote the current read/write Postgres node.
Remember: Patroni relies on the state of the DCS to take actions on the cluster.
You can use the :ref:`dcs_failsafe_mode` to alleviate that situation.
patronictl
----------
Do I need to run :ref:`patronictl` in the Patroni host?
No, you do not need to do that.
Running :ref:`patronictl` in the Patroni host is handy if you have access to the Patroni host because you can use the very same configuration file from the ``patroni`` agent for the :ref:`patronictl` application.
However, :ref:`patronictl` is basically a client and it can be executed from remote machines. You just need to provide it with enough configuration so it can reach the DCS and the REST API of the Patroni member(s).
Why did the information from one of my Patroni members disappear from the output of :ref:`patronictl_list` command?
Information shown by :ref:`patronictl_list` is based on the contents of the DCS.
If information about a member disappeared from the DCS it is very likely that the Patroni agent on that node is not running anymore, or it is not able to communicate with the DCS.
As the member is not able to update the information, the information eventually expires from the DCS, and consequently the member is not shown anymore in the output of :ref:`patronictl_list`.
Why is the information about one of my Patroni members not up-to-date in the output of :ref:`patronictl_list` command?
Information shown by :ref:`patronictl_list` is based on the contents of the DCS.
By default, that information is updated by Patroni roughly every ``loop_wait`` seconds.
In other words, even if everything is normally functional you may still see a "delay" of up to ``loop_wait`` seconds in the information stored in the DCS.
Be aware that that is not a rule, though. Some operations performed by Patroni cause it to immediately update the DCS information.
Configuration
-------------
What is the difference between dynamic configuration and local configuration?
Dynamic configuration (or global configuration) is the configuration stored in the DCS, and which is applied to all members of the Patroni cluster.
This is primarily where you should store your configuration.
Settings that are specific to a node, or settings that you would like to overwrite the global configuration with, you should set only on the desired Patroni member as a local configuration.
That local configuration can be specified either through the configuration file or through environment variables.
See more in :ref:`patroni_configuration`.
What are the types of configuration in Patroni, and what is the precedence?
The types are:
* Dynamic configuration: applied to all members;
* Local configuration: applied to the local member, overrides dynamic configuration;
* Environment configuration: applied to the local member, overrides both dynamic and local configuration.
**Note:** some Postgres GUCs can only be set globally, i.e., through dynamic configuration. Besides that, there are GUCs which Patroni enforces a hard-coded value.
See more in :ref:`patroni_configuration`.
Is there any facility to help me create my Patroni configuration file?
Yes, there is.
You can use ``patroni --generate-sample-config`` or ``patroni --generate-config`` commands to generate a sample Patroni configuration or a Patroni configuration based on an existing Postgres instance, respectively.
Please refer to :ref:`generate_sample_config` and :ref:`generate_config` for more details.
I changed my parameters under ``bootstrap.dcs`` configuration but Patroni is not applying the changes to the cluster members. What is wrong?
The values configured under ``bootstrap.dcs`` are only used when bootstrapping a fresh cluster. Those values will be written to the DCS during the bootstrap.
After the bootstrap phase finishes, you will only be able to change the dynamic configuration through the DCS.
Refer to the next question for more details.
How can I change my dynamic configuration?
You need to change the configuration in the DCS. That is accomplished either through:
* :ref:`patronictl_edit_config`; or
* A ``PATCH`` request to :ref:`config_endpoint`.
How can I change my local configuration?
You need to change the configuration file of the corresponding Patroni member and signal the Patroni agent with ``SIHGUP``. You can do that using either of these approaches:
* Send a ``POST`` request to the REST API :ref:`reload_endpoint`; or
* Run :ref:`patronictl_reload`; or
* Locally signal the Patroni process with ``SIGHUP``:
* If you started Patroni through systemd, you can use the command ``systemctl reload PATRONI_UNIT.service``, ``PATRONI_UNIT`` being the name of the Patroni service; or
* If you started Patroni through other means, you will need to identify the ``patroni`` process and run ``kill -s HUP PID``, ``PID`` being the process ID of the ``patroni`` process.
**Note:** there are cases where a reload through the :ref:`patronictl_reload` may not work:
* Expired REST API certificates: you can mitigate that by using the ``-k`` option of the :ref:`patronictl`;
* Wrong credentials: for example when changing ``restapi`` or ``ctl`` credentials in the configuration file, and using that same configuration file for Patroni and :ref:`patronictl`.
How can I change my environment configuration?
The environment configuration is only read by Patroni during startup.
With that in mind, if you change the environment configuration you will need to restart the corresponding Patroni agent.
Take care to not cause a failover in the cluster! You might be interested in checking :ref:`patronictl_pause`.
What occurs if I change a Postgres GUC that requires a reload?
When you change the dynamic or the local configuration as explained in the previous questions, Patroni will take care of reloading the Postgres configuration for you.
What occurs if I change a Postgres GUC that requires a restart?
Patroni will mark the affected members with a flag of ``pending restart``.
It is up to you to determine when and how to restart the members. That can be accomplished either through:
* :ref:`patronictl_restart`; or
* A ``POST`` request to :ref:`restart_endpoint`.
**Note:** some Postgres GUCs require a special management in terms of the order for restarting the Postgres nodes. Refer to :ref:`shared_memory_gucs` for more details.
What is the difference between ``etcd`` and ``etcd3`` in Patroni configuration?
``etcd`` uses the API version 2 of ``etcd``, while ``etcd3`` uses the API version 3 of ``etcd``.
Be aware that information stored by the API version 2 is not manageable by API version 3 and vice-versa.
We recommend that you configure ``etcd3`` instead of ``etcd`` because:
* API version 2 is disabled by default from Etcd v3.4 onward;
* API version 2 will be completely removed on Etcd v3.6.
I have ``use_slots`` enabled in my Patroni configuration, but when a cluster member goes offline for some time, the replication slot used by that member is dropped on the upstream node. What can I do to avoid that issue?
You can configure a permanent physical replication slot for the members.
Since Patroni ``3.2.0`` it is now possible to have member slots as permanent slots managed by Patroni.
Patroni will create the permanent physical slots on all nodes, and make sure to not remove the slots, as well as to advance the slots' LSN on all nodes according to the LSN that has been consumed by the member.
Later, if you decide to remove the corresponding member, it's **your responsability** to adjust the permanent slots configuration, otherwise Patroni will keep the slots around forever.
**Note:** on Patroni older than ``3.2.0`` you could still have member slots configured as permanent physical slots, however they would be managed only on the current leader. That is, in case of failover/switchover these slots would be created on the new leader, but that wouldn't guarantee that it had all WAL segments for the absent node.
**Note:** even with Patroni ``3.2.0`` there might be a small race condition. In the very beginning, when the slot is created on the replica it could be ahead of the same slot on the leader and in case if nobody is consuming the slot there is still a chance that some files could be missing after failover. With that in mind, it is recommended that you configure continuous archiving, which makes it possible to restore required WALs or perform PITR.
What is the difference between ``loop_wait``, ``retry_timeout`` and ``ttl``?
Patroni performs what we call a HA cycle from time to time. On each HA cycle it takes care of performing a series of checks on the cluster to determine its healthiness, and depending on the status it may take actions, like failing over to a standby.
``loop_wait`` determines for how long, in seconds, Patroni should sleep before performing a new cycle of HA checks.
``retry_timeout`` sets the timeout for retry operations on the DCS and on Postgres. For example: if the DCS is unresponsive for more than ``retry_timeout`` seconds, Patroni might demote the primary node as a security action.
``ttl`` sets the lease time on the ``leader`` lock in the DCS. If the current leader of the cluster is not able to renew the lease during its HA cycles for longer than ``ttl``, then the lease will expire and that will trigger a ``leader race`` in the cluster.
**Note:** when modifying these settings, please keep in mind that Patroni enforces the rule and minimal values described in :ref:`dynamic_configuration` section of the docs.
Postgres management
-------------------
Can I change Postgres GUCs directly in Postgres configuration?
You can, but you should avoid that.
Postgres configuration is managed by Patroni, and attempts to edit the configuration files may end up being frustrated by Patroni as it may eventually overwrite them.
There are a few options available to overcome the management performed by Patroni:
* Change Postgres GUCs through ``$PGDATA/postgresql.base.conf``; or
* Define a ``postgresql.custom_conf`` which will be used instead of ``postgresql.base.conf`` so you can manage that externally; or
* Change GUCs using ``ALTER SYSTEM`` / ``ALTER DATABASE`` / ``ALTER USER``.
You can find more information about that in the section :ref:`important_configuration_rules`.
In any case we recommend that you manage all the Postgres configuration through Patroni. That will centralize the management and make it easier to debug Patroni when needed.
Can I restart Postgres nodes directly?
No, you should **not** attempt to manage Postgres directly!
Any attempt of bouncing the Postgres server without Patroni can lead your cluster to face failovers.
If you need to manage the Postgres server, do that through the ways exposed by Patroni.
Is Patroni able to take over management of an already existing Postgres cluster?
Yes, it can!
Please refer to :ref:`existing_data` for detailed instructions.
How does Patroni manage Postgres?
Patroni takes care of bringing Postgres up and down by running the Postgres binaries, like ``pg_ctl`` and ``postgres``.
With that in mind you **MUST** disable any other sources that could manage the Postgres clusters, like the systemd units, e.g. ``postgresql.service``. Only Patroni should be able to start, stop and promote Postgres instances in the cluster. Not doing so may result in split-brain scenarios. For example: if the node running as a primary failed and the unit ``postgresql.service`` is enabled, it may bring Postgres back up and cause a split-brain.
Concepts and requirements
-------------------------
Which are the applications that make part of Patroni?
Patroni basically ships a couple applications:
* ``patroni``: This is the Patroni agent, which takes care of managing a Postgres node;
* ``patronictl``: This is a command-line utility used to interact with a Patroni cluster (perform switchovers, restarts, changes in the configuration, etc.). Please find more information in :ref:`patronictl`.
What is a ``standby cluster`` in Patroni?
It is a cluster that does not have any primary Postgres node running, i.e., there is no read/write member in the cluster.
These kinds of clusters exist to replicate data from another cluster and are usually useful when you want to replicate data across data centers.
There will be a leader in the cluster which will be a standby in charge of replicating changes from a remote Postgres node.
Then, there will be a set of standbys configured with cascading replication from such leader member.
**Note:** the standby cluster doesn't know anything about the source cluster which it is replicating from -- it can even use ``restore_command`` instead of WAL streaming, and may use an absolutely independent DCS cluster.
Refer to :ref:`standby_cluster` for more details.
What is a ``leader`` in Patroni?
A ``leader`` in Patroni is like a coordinator of the cluster.
In a regular Patroni cluster, the ``leader`` will be the read/write node.
In a standby Patroni cluster, the ``leader`` (AKA ``standby leader``) will be in charge of replicating from a remote Postgres node, and cascading those changes to the other members of the standby cluster.
Does Patroni require a minimum number of Postgres nodes in the cluster?
No, you can run Patroni with any number of Postgres nodes.
Remember: Patroni is decoupled from the DCS.
What does ``pause`` mean in Patroni?
Pause is an operation exposed by Patroni so the user can ask Patroni to step back in regards to Postgres management.
That is mainly useful when you want to perform maintenance on the cluster, and would like to avoid that Patroni takes decisions related with HA, like failing over to a standby when you stop the primary.
You can find more information about that in :ref:`pause`.
Automatic failover
------------------
How does the automatic failover mechanism of Patroni work?
Patroni automatic failover is based on what we call ``leader race``.
Patroni stores the cluster's status in the DCS, among them a ``leader`` lock which holds the name of the Patroni member which is the current ``leader`` of the cluster.
That ``leader`` lock has a time-to-live associated with it. If the leader node fails to update the lease of the ``leader`` lock in time, the key will eventually expire from the DCS.
When the ``leader`` lock expires, it triggers what Patroni calls a ``leader race``: all nodes start performing checks to determine if they are the best candidates for taking over the ``leader`` role.
Some of these checks include calls to the REST API of all other Patroni members.
All Patroni members that find themselves as the best candidate for taking over the ``leader`` lock will attempt to do so.
The first Patroni member that is able to take the ``leader`` lock will promote itself to a read/write node (or ``standby leader``), and the others will be configured to follow it.
Can I temporarily disable automatic failover in the Patroni cluster?
Yes, you can!
You can achieve that by temporarily pausing the cluster.
This is typically useful for performing maintenance.
When you want to resume the automatic failover of the cluster, you just need to unpause it.
You can find more information about that in :ref:`pause`.
Bootstrapping and standbys creation
-----------------------------------
How does Patroni create a primary Postgres node? What about a standby Postgres node?
By default Patroni will use ``initdb`` to bootstrap a fresh cluster, and ``pg_basebackup`` to create standby nodes from a copy of the ``leader`` member.
You can customize that behavior by writing your custom bootstrap methods, and your custom replica creation methods.
Custom methods are usually useful when you want to restore backups created by backup tools like pgBackRest or Barman, for example.
For detailed information please refer to :ref:`custom_bootstrap` and :ref:`custom_replica_creation`.
Monitoring
----------
How can I monitor my Patroni cluster?
Patroni exposes a couple handy endpoints in its :ref:`rest_api`:
* ``/metrics``: exposes monitoring metrics in a format that can be consumed by Prometheus;
* ``/patroni``: exposes the status of the cluster in a JSON format. The information shown here is very similar to what is shown by the ``/metrics`` endpoint.
You can use those endpoints to implement monitoring checks.
+5
View File
@@ -22,18 +22,23 @@ Currently supported PostgreSQL versions: 9.3 to 16.
:caption: Contents:
README
installation
patroni_configuration
rest_api
patronictl
replica_bootstrap
replication_modes
standby_cluster
watchdog
pause
dcs_failsafe_mode
kubernetes
citus
existing_data
tools_integration
security
ha_multi_dc
faq
releases
CONTRIBUTING
+198
View File
@@ -0,0 +1,198 @@
.. _installation:
Installation
============
Pre-requirements for Mac OS
---------------------------
To install requirements on a Mac, run the following:
.. code-block:: shell
brew install postgresql etcd haproxy libyaml python
.. _psycopg2_install_options:
Psycopg
-------
Starting from `psycopg2-2.8`_ 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.
There are a few options available:
1. Use the package manager from your distro
.. code-block:: shell
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the :ref:`list of dependencies <extras>` when installing Patroni with pip.
.. _extras:
General installation for pip
----------------------------
Patroni can be installed with pip:
.. code-block:: shell
pip install patroni[dependencies]
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 Distributed Configuration Store (DCS)
consul
`python-consul` module in order to use Consul as DCS
zookeeper
`kazoo` module in order to use Zookeeper as DCS
exhibitor
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
kubernetes
`kubernetes` module in order to use Kubernetes as DCS in Patroni
raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto3` in order to use AWS callbacks
jsonlogger
`python-json-logger` module in order to enable :ref:`logging <log_settings>` in json format
all
all of the above (except psycopg family)
psycopg
`psycopg[binary]>=3.0.0` module
psycopg2
`psycopg2>=2.5.4` module
psycopg2-binary
`psycopg2-binary` module
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
.. code-block:: shell
pip install patroni[psycopg3,etcd3,aws]
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
independently of Patroni.
.. _package_installation:
Package installation on Linux
-----------------------------
Patroni packages may be available for your operating system, produced by the Postgres community for:
* RHEL, RockyLinux, AlmaLinux;
* Debian and Ubuntu;
* SUSE Enterprise Linux.
You can also find packages for direct dependencies of Patroni, like python modules that might not be available in
the official operating system repositories.
For more information see the `PGDG repository`_ documentation.
If you are on a RedHat Enterprise Linux derivative operating system you may also require packages from EPEL, see
`EPEL repository`_ documentation.
Once you have installed the PGDG repository for your OS you can install patroni.
.. note::
Patroni packages are not maintained by the Patroni developers, but rather by the Postgres community. If you
require support please first try connecting on `Postgres slack`_.
Installing on Debian derivatives
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
With PGDG repo installed, see :ref:`above <package_installation>`, install Patroni via apt run:
.. code-block:: shell
apt-get install patroni
Installing on RedHat derivatives
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
With PGDG repo installed, see :ref:`above <package_installation>`, install patroni with an etcd DCS via dnf on RHEL 9
(and derivatives) run:
.. code-block:: shell
dnf install patroni patroni-etcd
You can install etcd from PGDG if your RedHat derivative distribution does not provide packages. On the nodes that will
host the DCS run:
.. code-block:: shell
dnf install 'dnf-command(config-manager)'
dnf config-manager --enable pgdg-rhel9-extras
dnf install etcd
You can replace the version of RHEL with `8` in the repo to make `pgdg-rhel8-extras` if needed. The repo name is still
`pgdg-rhelN-extras` on RockyLinux, AlmaLinux, Oracle Linux, etc...
Installing on SUSE Enterprise Linux
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You might need to enable the SUSE PackageHub repositories for some dependencies. see `SUSE PackageHub`_ documentation.
For SLES 15 with PGDG repo installed, see :ref:`above <package_installation>`, you can install patroni using:
.. code-block:: shell
zypper install patroni patroni-etcd
With the SUSE PackageHub repo enabled you can also install etcd:
.. code-block:: shell
SUSEConnect -p PackageHub/15.5/x86_64
zypper install etcd
Upgrading
---------
Upgrading patroni is a very simple process, just update the software installation and restart the Patroni daemon on
each node in the cluster.
However, restarting the Patroni daemon will result in a Postgres database restart. In some situations this may cause
a failover of the primary node in your cluster, therefore it is recommended to put the cluster into maintenance mode
until the Patroni daemon restart has been completed.
To put the cluster in maintenance mode, run the following command on one of the patroni nodes:
.. code-block:: shell
patronictl pause --wait
Then on each node in the cluster, perform the package upgrade required for your OS:
.. code-block:: shell
apt-get update && apt-get install patroni patroni-etcd
Restart the patroni daemon process on each node:
.. code-block:: shell
systemctl restart patroni
Then finally resume monitoring of Postgres with patroni to take it out of maintenance mode:
.. code-block:: shell
patronictl resume --wait
The cluster will now be full operational with the new version of Patroni.
.. _psycopg2-2.8: http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/
.. _PGDG repository: https://www.postgresql.org/download/linux/
.. _EPEL repository: https://docs.fedoraproject.org/en-US/epel/
.. _SUSE PackageHub: https://packagehub.suse.com/how-to-use/
.. _Postgres slack: http://pgtreats.info/slack-invite
+156 -11
View File
@@ -15,7 +15,7 @@ There are 3 types of Patroni configuration:
- Global :ref:`dynamic configuration <dynamic_configuration>`.
These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
Dynamic configuration can be set at any time using ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
Dynamic configuration can be set at any time using :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
If the options changed are not part of the startup configuration, they are applied asynchronously (upon the next wake up cycle)
to every node, which gets subsequently reloaded.
If the node requires a restart to apply the configuration (for `PostgreSQL parameters <https://www.postgresql.org/docs/current/view-pg-settings.html>`__ with context postmaster, if their values
@@ -24,12 +24,13 @@ There are 3 types of Patroni configuration:
- Local :ref:`configuration file <yaml_configuration>` (patroni.yml).
These options are defined in the configuration file and take precedence over dynamic configuration.
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing :ref:`patronictl_reload`. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
- :ref:`Environment configuration <environment>`.
It is possible to set/override some of the "Local" configuration parameters with environment variables.
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
.. _important_configuration_rules:
Important rules
---------------
@@ -44,7 +45,6 @@ Some of the PostgreSQL parameters **must hold the same values on the primary and
- **max_worker_processes**: 8
- **max_prepared_transactions**: 0
- **wal_level**: hot_standby
- **wal_log_hints**: on
- **track_commit_timestamp**: off
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, **Patroni restricts setting their values to the** :ref:`dynamic configuration <dynamic_configuration>`.
@@ -62,6 +62,7 @@ There are some other Postgres parameters controlled by Patroni:
- **port** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- **cluster_name** - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
- **hot_standby: on**
- **wal_log_hints: on** - for Postgres 9.4 and newer.
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
@@ -70,15 +71,16 @@ There also are some parameters like **postgresql.listen**, **postgresql.data_dir
When applying the local or dynamic configuration options, the following actions are taken:
- The node first checks if there is a `postgresql.base.conf` or if the ``custom_conf`` parameter is set.
- If the ``custom_conf`` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
- If there is no ``custom_conf``` nor `postgresql.base.conf`, the original `postgresql.conf`` is taken and renamed to postgresql.base.conf.
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf`` and an include is set in
postgresql.conf to the used base configuration (either `postgresql.base.conf` or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
- The node first checks if there is a `postgresql.base.conf` file or if the ``custom_conf`` parameter is set.
- If the ``custom_conf`` parameter is set, the file it specifies is used as the base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and is used as the base configuration.
- If there is no ``custom_conf`` nor `postgresql.base.conf`, the original `postgresql.conf` is renamed to `postgresql.base.conf` and used as the base configuration.
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf` and an include is set in
`postgresql.conf` to the base configuration (either `postgresql.base.conf` or the file at ``custom_conf``).
Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present or not.
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
- If an option that requires restart is changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag is set on that node. This flag is reset on any restart.
The parameters would be applied in the following order (run-time are given the highest priority):
@@ -89,6 +91,43 @@ The parameters would be applied in the following order (run-time are given the h
This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
.. _shared_memory_gucs:
PostgreSQL parameters that touch shared memory
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PostgreSQL has some parameters that determine the size of the shared memory used by them:
- **max_connections**
- **max_prepared_transactions**
- **max_locks_per_transaction**
- **max_wal_senders**
- **max_worker_processes**
Changing these parameters require a PostgreSQL restart to take effect, and their shared memory structures cannot be smaller on the standby nodes than on the primary node.
As explained before, Patroni restrict changing their values through :ref:`dynamic configuration <dynamic_configuration>`, which usually consists of:
1. Applying changes through :ref:`patronictl_edit_config` (or via REST API ``/config`` endpoint)
2. Restarting nodes through :ref:`patronictl_restart` (or via REST API ``/restart`` endpoint)
**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through :ref:`patronictl_restart` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node.
However, as those settings manage shared memory, some extra care should be taken when restarting the nodes:
* If you want to **increase** the value of any of those settings:
1. Restart all standbys first
2. Restart the primary after that
* If you want to **decrease** the value of any of those settings:
1. Restart the primary first
2. Restart all standbys after that
**Note:** if you attempt to restart all nodes in one go after **decreasing** the value of any of those settings, Patroni will ignore the change and restart the standby with the original setting value, thus requiring that you restart the standbys again later. Patroni does that to prevent the standby to enter in an infinite crash loop, because PostgreSQL quits with a `FATAL` message if you attempt to set any of those parameters to a value lower than what is visible in ``pg_controldata`` on the Standby node. In other words, we can only decrease the setting on the standby once its ``pg_controldata`` is up-to-date with the primary in regards to these changes on the primary.
More information about that can be found at `PostgreSQL Administrator's Overview <https://www.postgresql.org/docs/current/hot-standby.html#HOT-STANDBY-ADMIN>`__.
Patroni configuration parameters
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -106,3 +145,109 @@ Also the following Patroni configuration options **can be changed only dynamical
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
.. _validate_generate_config:
Configuration generation and validation
---------------------------------------
Patroni provides command-line interfaces for a Patroni :ref:`local configuration <yaml_configuration>` generation and validation. Using the ``patroni`` executable you can:
- Create a sample local Patroni configuration;
- Create a Patroni configuration file for the locally running PostgreSQL instance (e.g. as a preparation step for the :ref:`Patroni integration <existing_data>`);
- Validate a given Patroni configuration file.
.. _generate_sample_config:
Sample Patroni configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code:: text
patroni --generate-sample-config [configfile]
Description
"""""""""""
Generate a sample Patroni configuration file in ``yaml`` format.
Parameter values are defined using the :ref:`Environment configuration <environment>`, otherwise, if not set, the defaults used in Patroni or the ``#FIXME`` string for the values that should be later defined by the user.
Some default values are defined based on the local setup:
- **postgresql.listen**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
- **postgresql.authentication.rewind**: is only defined if the PostgreSQL version can be defined from the binary and the version is 11 or later.
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
Parameters
""""""""""
``configfile`` - full path to the configuration file used to store the result. If not provided, the result is sent to ``stdout``.
.. _generate_config:
Patroni configuration for a running instance
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code:: text
patroni --generate-config [--dsn DSN] [configfile]
Description
"""""""""""
Generate a Patroni configuration in ``yaml`` format for the locally running PostgreSQL instance.
Either the provided DSN (takes precedence) or PostgreSQL `environment variables <https://www.postgresql.org/docs/current/libpq-envars.html>`__ will be used for the PostgreSQL connection. If the password is not provided, it should be entered via prompt.
All the non-internal GUCs defined in the source Postgres instance, independently if they were set through a configuration file, through the postmaster command-line, or through environment variables, will be used as the source for the following Patroni configuration parameters:
- **scope**: ``cluster_name`` GUC value;
- **postgresql.listen**: ``listen_addresses`` and ``port`` GUC values;
- **postgresql.datadir**: ``data_directory`` GUC value;
- **postgresql.parameters**: ``archive_command``, ``restore_command``, ``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``, ``config_file`` GUC values;
- **bootstrap.dcs**: all other gathered PostgreSQL GUCs.
If ``scope``, ``postgresql.listen`` or ``postgresql.datadir`` is not set from the Postgres GUCs, the respective :ref:`Environment configuration <environment>` value is used.
Other rules applied for the values definition:
- **name**: ``PATRONI_NAME`` environment variable value if set, otherwise the current machine's hostname.
- **postgresql.bin_dir**: path to the Postgres binaries gathered from the running instance.
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the port used for the instance connection or the ``port`` GUC value.
- **postgresql.authentication.superuser**: the configuration used for the instance connection;
- **postgresql.pg_hba**: the lines gathered from the source instance's ``hba_file``.
- **postgresql.pg_ident**: the lines gathered from the source instance's ``ident_file``.
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
Other parameters defined using :ref:`Environment configuration <environment>` are also included into the configuration.
Parameters
""""""""""
``configfile``
Full path to the configuration file used to store the result. If not provided, result is sent to ``stdout``.
``dsn``
Optional DSN string for the local PostgreSQL instance to get GUC values from.
Validate Patroni configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code:: text
patroni --validate-config [configfile]
Description
"""""""""""
Validate the given Patroni configuration and print the information about the failed checks.
Parameters
""""""""""
``configfile``
Full path to the configuration file to check. If not given or file does not exist, will try to read from the ``PATRONI_CONFIG_VARIABLE`` environment variable or, if not set, from the :ref:`Patroni environment variables <environment>`.
+1975
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -19,7 +19,7 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
- 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 primary node.
- Manual unscheduled restart, manual unscheduled failover/switchover and reinitialize are allowed. No scheduled action is allowed. Manual switchover is only allowed if the node to switch over to is specified.
- If 'parallel' primaries are detected by Patroni, it emits a warning, but does not demote the primary without the leader lock.
@@ -32,6 +32,6 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
User guide
----------
``patronictl`` supports ``pause`` and ``resume`` commands.
``patronictl`` supports :ref:`pause <patronictl_pause>` and :ref:`resume <patronictl_resume>` commands.
One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
+243
View File
@@ -3,6 +3,249 @@
Release notes
=============
Version 3.3.0
-------------
**New features**
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
It allows to specify the authentication credentials to use for the connection.
- Add a contrib script for ``Barman`` integration (Israel Barth Rubio)
Provide an application ``patroni_barman`` that allows to perform ``Barman`` operations remotely and can be used as a custom bootstrap/custom replica method or as an ``on_role_change`` callback. Please check :ref:`here <tools_integration>` for more information.
- Support JSON log format (alisalemmi)
Apart from ``plain``, Patroni now also supports ``json`` log format. Requires ``python-json-logger`` library to be installed.
- Show ``pending_restart_reason`` information (Polina Bungina)
Provide extended information about the PostgreSQL parameters that caused ``pending_restart`` flag to be set. Both ``patronictl list`` and ``/patroni`` REST API endpoint now show the parameters names and their "diff" as ``pending_restart_reason``.
- Implement ``nostream`` tag (Grigory Smolkin)
If ``nostream`` tag is set to ``true``, the node will not use replication protocol to stream WAL but instead rely on archive recovery (if ``restore_command`` is configured). It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas.
**Improvements**
- Implement validation of the log section (Alexander Kukushkin)
Until now validator was not checking the correctness of the logging configuration provided.
- Improve logging for PostgreSQL parameters change (Polina Bungina)
Convert old values to a human-readable format and log information about the ``pg_controldata`` vs Patroni global configuration mismatch.
**Bugfixes**
- Properly filter out not allowed ``pg_basebackup`` options (Israel Barth Rubio)
Due to a bug, Patroni was not properly filtering out the not allowed options configured for the ``basebackup`` replica bootstrap method, when provided in the ``- setting: value`` format.
- Fix ``etcd3`` authentication error handling (Alexander Kukushkin)
Always retry one time on ``etcd3`` authentication error if authentication was not done right before executing the request. Also, do not restart watchers on reauthentication.
- Improve logic of the validator files discovery (Waynerv)
Use ``importlib`` library to discover the files with available configuration parameters when possible (for Python 3.9+). This implementation is more stable and doesn't break the Patroni distributions based on ``zip`` archives.
- Use ``target_session_attrs`` only when multiple hosts are specified in the ``standby_cluster`` section (Alexander Kukushkin)
``target_session_attrs=read-write`` is now added to the ``primary_conninfo`` on the standby leader node only when ``standby_cluster.host`` section contains multiple hosts separated by commas.
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
.. warning::
All older Partoni versions are not compatible with ``ydiff`` 1.3+. Please upgrade Patroni, use ``ydiff`` version <1.3, or install ``cdiff``.
Version 3.2.2
-------------
**Bugfixes**
- Don't let replica restore initialize key when DCS was wiped (Alexander Kukushkin)
It was happening in the method where Patroni was supposed to take over a standalone PG cluster.
- Use consistent read when fetching just updated sync key from Consul (Alexander Kukushkin)
Consul doesn't provide any interface to immediately get ``ModifyIndex`` for the key that we just updated, therefore we have to perform an explicit read operation. Since stale reads are allowed by default, we sometimes used to get an outdated version of the key.
- Reload Postgres config if a parameter that requires restart was reset to the original value (Polina Bungina)
Previously Patroni wasn't updating the config, but only resetting the ``pending_restart``.
- Fix erroneous inverted logic of the confirmation prompt message when doing a failover to an async candidate in synchronous mode (Polina Bungina)
The problem existed only in ``patronictl``.
- Exclude leader from failover candidates in ``patronictl`` (Polina Bungina)
If the cluster is healthy, failing over to an existing leader is no-op.
- Create Citus database and extension idempotently (Alexander Kukushkin, Zhao Junwang)
It will allow to create them in the ``post_bootstrap`` script in case if there is a need to add some more dependencies to the Citus database.
- Don't filter our contradictory ``nofailover`` tag (Polina Bungina)
The configuration ``{nofailover: false, failover_priority: 0}`` set on a node didn't allow it to participate in the race, while it should, because ``nofailover`` tag should take precedence.
- Fixed PyInstaller frozen issue (Sophia Ruan)
The ``freeze_support()`` was called after ``argparse`` and as a result, Patroni wasn't able to start Postgres.
- Fixed bug in the config generator for ``patronictl`` and ``Citus`` configuration (Israel Barth Rubio)
It prevented ``patronictl`` and ``Citus`` configuration parameters set via environment variables from being written into the generated config.
- Restore recovery GUCs and some Patroni-managed parameters when joining a running standby (Alexander Kukushkin)
Patroni was failing to restart Postgres v12 onwards with an error about missing ``port`` in one of the internal structures.
- Fixes around ``pending_restart`` flag (Polina Bungina)
Don't expose ``pending_restart`` when in custom bootstrap with ``recovery_target_action = promote`` or when someone changed ``hot_standby`` or ``wal_log_hints`` using for example ``ALTER SYSTEM``.
Version 3.2.1
-------------
**Bugfixes**
- Limit accepted values for ``--format`` argument in ``patronictl`` (Alexander Kukushkin)
It used to accept any arbitrary string and produce no output if the value wasn't recognized.
- Verify that replica nodes received checkpoint LSN on shutdown before releasing the leader key (Alexander Kukushkin)
Previously in some cases, we were using LSN of the SWITCH record that is followed by CHECKPOINT (if archiving mode is enabled). As a result the former primary sometimes had to do ``pg_rewind``, but there would be no data loss involved.
- Do a real HTTP request when performing node name uniqueness check (Alexander Kukushkin)
When running Patroni in containers it is possible that the traffic is routed using ``docker-proxy``, which listens on the port and accepts incoming connections. It was causing false positives.
- Fixed Citus support with Etcd v2 (Alexander Kukushkin)
Patroni was failing to deploy a new Citus cluster with Etcd v2.
- Fixed ``pg_rewind`` behavior with Postgres v16+ (Alexander Kukushkin)
The error message format of ``pg_waldump`` changed in v16 which caused ``pg_rewind`` to be called by Patroni even when it was not necessary.
- Fixed bug with custom bootstrap (Alexander Kukushkin)
Patroni was falsely applying ``--command`` argument, which is a bootstrap command itself.
- Fixed the issue with REST API health check endpoints (Sophia Ruan)
There were chances that after Postgres restart it could return ``unknown`` state for Postgres because connections were not properly closed.
- Cache ``postgres --describe-config`` output results (Waynerv)
They are used to figure out which GUCs are available to validate PostgreSQL configuration and we don't expect this list to change while Patroni is running.
Version 3.2.0
-------------
**Deprecation notice**
- The ``bootstrap.users`` support will be removed in version 4.0.0. If you need to create users after deploying a new cluster please use the ``bootstrap.post_bootstrap`` hook for that.
**Breaking changes**
- Enforce ``loop_wait + 2*retry_timeout <= ttl`` rule and hard-code minimal possible values (Alexander Kukushkin)
Minimal values: ``loop_wait=2``, ``retry_timeout=3``, ``ttl=20``. In case values are smaller or violate the rule they are adjusted and a warning is written to Patroni logs.
**New features**
- Failover priority (Mark Pekala)
With the help of ``tags.failover_priority`` it's now possible to make a node more preferred during the leader race. More details in the documentation (ref tags).
- Implemented ``patroni --generate-config [--dsn DSN]`` and ``patroni --generate-sample-config`` (Polina Bungina)
It allows to generate a config file for the running PostgreSQL cluster or a sample config file for the new Patroni cluster.
- Use a dedicated connection to Postgres for Patroni REST API (Alexander Kukushkin)
It helps to avoid blocking the main heartbeat loop if the system is under stress.
- Enrich some endpoints with the ``name`` of the node (sskserk)
For the monitoring endpoint ``name`` is added next to the ``scope`` and for metrics endpoint the ``name`` is added to tags.
- Ensure strict failover/switchover difference (Polina Bungina)
Be more precise in log messages and allow failing over to an asynchronous node in a healthy synchronous cluster.
- Make permanent physical replication slots behave similarly to permanent logical slots (Alexander Kukushkin)
Create permanent physical replication slots on all nodes that are allowed to become the leader and use ``pg_replication_slot_advance()`` function to advance ``restart_lsn`` for slots on standby nodes.
- Add capability of specifying namespace through ``--dcs`` argument in ``patronictl`` (Israel Barth Rubio)
It could be handy if ``patronictl`` is used without a configuration file.
- Add support for additional parameters in custom bootstrap configuration (Israel Barth Rubio)
Previously it was only possible to add custom arguments to the ``command`` and now one could list them as a mapping.
**Improvements**
- Set ``citus.local_hostname`` GUC to the same value which is used by Patroni to connect to the Postgres (Alexander Kukushkin)
There are cases when Citus wants to have a connection to the local Postgres. By default it uses ``localhost``, which is not always available.
**Bugfixes**
- Ignore ``synchronous_mode`` setting in a standby cluster (Polina Bungina)
Postgres doesn't support cascading synchronous replication and not ignoring ``synchronous_mode`` was breaking a switchover in a standby cluster.
- Handle SIGCHLD for ``on_reload`` callback (Alexander Kukushkin)
Not doing so results in a zombie process, which is reaped only when the next ``on_reload`` is executed.
- Handle ``AuthOldRevision`` error when working with Etcd v3 (Alexander Kukushkin, Kenny Do)
The error is raised if Etcd is configured to use JWT and when the user database in Etcd is updated.
Version 3.1.2
-------------
**Bugfixes**
- Fixed bug with ``wal_keep_size`` checks (Alexander Kukushkin)
The ``wal_keep_size`` is a GUC that normally has a unit and Patroni was failing to cast its value to ``int``. As a result the value of ``bootstrap.dcs`` was not written to the ``/config`` key afterwards.
- Detect and resolve inconsistencies between ``/sync`` key and ``synchronous_standby_names`` (Alexander Kukushkin)
Normally, Patroni updates ``/sync`` and ``synchronous_standby_names`` in a very specific order, but in case of a bug or when someone manually reset ``synchronous_standby_names``, Patroni was getting into an inconsistent state. As a result it was possible that the failover happens to an asynchronous node.
- Read GUC's values when joining running Postgres (Alexander Kukushkin)
When restarted in ``pause``, Patroni was discarding the ``synchronous_standby_names`` GUC from the ``postgresql.conf``. To solve it and avoid similar issues, Patroni will read GUC's value if it is joining an already running Postgres.
- Silenced annoying warnings when checking for node uniqueness (Alexander Kukushkin)
``WARNING`` messages are produced by ``urllib3`` if Patroni is quickly restarted.
Version 3.1.1
-------------
+55 -61
View File
@@ -1,3 +1,5 @@
.. _replica_imaging_and_bootstrap:
Replica imaging and bootstrap
=============================
@@ -43,19 +45,49 @@ in the configuration files, Patroni supplies two cluster-specific ones:
Passing these two additional flags can be disabled by setting a special ``no_params`` parameter to ``True``.
If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
If the bootstrap script returns ``0``, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed,
cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap.
If a ``recovery_conf`` block is defined in the same section as the custom bootstrap method, Patroni will generate a
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
``recovery.conf`` before starting the newly bootstrapped instance (or set the recovery settings on Postgres configuration if
running PostgreSQL >= 12).
Typically, such recovery configuration should contain at least one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists.
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you.
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists (PostgreSQL <= 11).
Similarly, in that case Patroni will not remove the existing ``recovery.signal`` or ``standby.signal`` if either exists, nor will it override the configured recovery settings (PostgreSQL >= 12).
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate recovery configuration for you.
Besides that, any additional key/value pairs informed in the custom bootstrap method configuration will be passed as arguments to ``command`` in the format ``--name=value``. For example:
.. code:: YAML
bootstrap:
method: <custom_bootstrap_method_name>
<custom_bootstrap_method_name>:
command: <path_to_custom_bootstrap_script>
arg1: value1
arg2: value2
Makes the configured ``command`` to be called additionally with ``--arg1=value1 --arg2=value2`` command-line arguments.
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
As an example, you are able to bootstrap a fresh Patroni cluster from a Barman backup with a configuration like this:
.. code:: YAML
bootstrap:
method: barman
barman:
keep_existing_recovery_conf: true
command: patroni_barman --api-url https://barman-host:7480 recover
barman-server: my_server
ssh-command: ssh postgres@patroni-host
.. note::
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
.. _custom_replica_creation:
@@ -110,6 +142,24 @@ example: pgbackrest
basebackup:
max-rate: '100M'
example: Barman
.. code:: YAML
postgresql:
create_replica_methods:
- barman
- basebackup
barman:
command: patroni_barman --api-url https://barman-host:7480 recover
barman-server: my_server
ssh-command: ssh postgres@patroni-host
basebackup:
max-rate: '100M'
.. note::
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
@@ -169,59 +219,3 @@ and
- waldir: /pg-wal-mount/external-waldir
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
.. _standby_cluster:
Standby cluster
---------------
Another available option is to run a "standby cluster", that contains only of
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 node.
* cascade replicas, that are replicating from standby leader.
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
creating replicas, when cluster is detached and functions as a normal cluster,
which is controlled by `create_replica_methods` in `postgresql` section. Both
"standby" and "normal" `create_replica_methods` reference keys in `postgresql`
section.
To configure such cluster you need to specify the section ``standby_cluster``
in a patroni configuration:
.. code:: YAML
bootstrap:
dcs:
standby_cluster:
host: 1.2.3.4
port: 5432
primary_slot_name: patroni
create_replica_methods:
- basebackup
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
@@ -53,7 +53,7 @@ are available. As a downside, the primary is not be available for writes
blocking all client write requests until at least one synchronous replica comes
up.
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby. Setting tag ``nostream`` to true will also have the same effect.
Synchronous mode can be switched on and off via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
+308 -66
View File
@@ -3,7 +3,7 @@
Patroni REST API
================
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
Health check endpoints
----------------------
@@ -92,26 +92,188 @@ Monitoring endpoint
The ``GET /patroni`` is used by Patroni during the leader race. It also could be used by your monitoring system. The JSON document produced by this endpoint has the same structure as the JSON produced by the health check endpoints.
**Example:** A healthy cluster
.. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq .
{
"state": "running",
"postmaster_start_time": "2019-09-24 09:22:32.555 CEST",
"postmaster_start_time": "2023-08-18 11:03:37.966359+00:00",
"role": "master",
"server_version": 110005,
"cluster_unlocked": false,
"server_version": 150004,
"xlog": {
"location": 25624640
"location": 67395656
},
"timeline": 3,
"database_system_identifier": "6739877027151648096",
"timeline": 1,
"replication": [
{
"usename": "replicator",
"application_name": "patroni2",
"client_addr": "10.89.0.6",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
},
{
"usename": "replicator",
"application_name": "patroni3",
"client_addr": "10.89.0.2",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
}
],
"dcs_last_seen": 1692356718,
"tags": {
"clonefrom": true
},
"database_system_identifier": "7268616322854375442",
"patroni": {
"version": "1.6.0",
"scope": "batman"
"version": "3.1.0",
"scope": "demo",
"name": "patroni1"
}
}
**Example:** An unlocked cluster
.. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq .
{
"state": "running",
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
"role": "replica",
"server_version": 150004,
"xlog": {
"received_location": 67419744,
"replayed_location": 67419744,
"replayed_timestamp": null,
"paused": false
},
"timeline": 1,
"replication": [
{
"usename": "replicator",
"application_name": "patroni2",
"client_addr": "10.89.0.6",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
},
{
"usename": "replicator",
"application_name": "patroni3",
"client_addr": "10.89.0.2",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
}
],
"cluster_unlocked": true,
"dcs_last_seen": 1692356928,
"tags": {
"clonefrom": true
},
"database_system_identifier": "7268616322854375442",
"patroni": {
"version": "3.1.0",
"scope": "demo",
"name": "patroni1"
}
}
**Example:** An unlocked cluster with :ref:`DCS failsafe mode <dcs_failsafe_mode>` enabled
.. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq .
{
"state": "running",
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
"role": "replica",
"server_version": 150004,
"xlog": {
"location": 67420024
},
"timeline": 1,
"replication": [
{
"usename": "replicator",
"application_name": "patroni2",
"client_addr": "10.89.0.6",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
},
{
"usename": "replicator",
"application_name": "patroni3",
"client_addr": "10.89.0.2",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
}
],
"cluster_unlocked": true,
"failsafe_mode_is_active": true,
"dcs_last_seen": 1692356928,
"tags": {
"clonefrom": true
},
"database_system_identifier": "7268616322854375442",
"patroni": {
"version": "3.1.0",
"scope": "demo",
"name": "patroni1"
}
}
**Example:** A cluster with the :ref:`pause mode <pause>` enabled
.. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq .
{
"state": "running",
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
"role": "replica",
"server_version": 150004,
"xlog": {
"location": 67420024
},
"timeline": 1,
"replication": [
{
"usename": "replicator",
"application_name": "patroni2",
"client_addr": "10.89.0.6",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
},
{
"usename": "replicator",
"application_name": "patroni3",
"client_addr": "10.89.0.2",
"state": "streaming",
"sync_state": "async",
"sync_priority": 0
}
],
"pause": true,
"dcs_last_seen": 1692356928,
"tags": {
"clonefrom": true
},
"database_system_identifier": "7268616322854375442",
"patroni": {
"version": "3.1.0",
"scope": "demo",
"name": "patroni1"
}
}
Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` endpoint.
@@ -121,64 +283,70 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
# HELP patroni_version Patroni semver without periods. \
# TYPE patroni_version gauge
patroni_version{scope="batman"} 020103
patroni_version{scope="batman",name="patroni1"} 020103
# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.
# TYPE patroni_postgres_running gauge
patroni_postgres_running{scope="batman"} 1
patroni_postgres_running{scope="batman",name="patroni1"} 1
# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.
# TYPE patroni_postmaster_start_time gauge
patroni_postmaster_start_time{scope="batman"} 1657656955.179243
patroni_postmaster_start_time{scope="batman",name="patroni1"} 1657656955.179243
# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.
# TYPE patroni_master gauge
patroni_master{scope="batman"} 1
patroni_master{scope="batman",name="patroni1"} 1
# HELP patroni_primary Value is 1 if this node is the leader, 0 otherwise.
# TYPE patroni_primary gauge
patroni_primary{scope="batman",name="patroni1"} 1
# HELP patroni_xlog_location Current location of the Postgres transaction log, 0 if this node is not the leader.
# TYPE patroni_xlog_location counter
patroni_xlog_location{scope="batman"} 22320573386952
patroni_xlog_location{scope="batman",name="patroni1"} 22320573386952
# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.
# TYPE patroni_standby_leader gauge
patroni_standby_leader{scope="batman"} 0
patroni_standby_leader{scope="batman",name="patroni1"} 0
# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.
# TYPE patroni_replica gauge
patroni_replica{scope="batman"} 0
patroni_replica{scope="batman",name="patroni1"} 0
# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.
# TYPE patroni_sync_standby gauge
patroni_sync_standby{scope="batman"} 0
patroni_sync_standby{scope="batman",name="patroni1"} 0
# HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica.
# TYPE patroni_xlog_received_location counter
patroni_xlog_received_location{scope="batman"} 0
patroni_xlog_received_location{scope="batman",name="patroni1"} 0
# HELP patroni_xlog_replayed_location Current location of the replayed Postgres transaction log, 0 if this node is not a replica.
# TYPE patroni_xlog_replayed_location counter
patroni_xlog_replayed_location{scope="batman"} 0
patroni_xlog_replayed_location{scope="batman",name="patroni1"} 0
# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed Postgres transaction log, 0 if null.
# TYPE patroni_xlog_replayed_timestamp gauge
patroni_xlog_replayed_timestamp{scope="batman"} 0
patroni_xlog_replayed_timestamp{scope="batman",name="patroni1"} 0
# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.
# TYPE patroni_xlog_paused gauge
patroni_xlog_paused{scope="batman"} 0
patroni_xlog_paused{scope="batman",name="patroni1"} 0
# HELP patroni_postgres_streaming Value is 1 if Postgres is streaming, 0 otherwise.
# TYPE patroni_postgres_streaming gauge
patroni_postgres_streaming{scope="batman"} 1
patroni_postgres_streaming{scope="batman",name="patroni1"} 1
# HELP patroni_postgres_in_archive_recovery Value is 1 if Postgres is replicating from archive, 0 otherwise.
# TYPE patroni_postgres_in_archive_recovery gauge
patroni_postgres_in_archive_recovery{scope="batman"} 0
patroni_postgres_in_archive_recovery{scope="batman",name="patroni1"} 0
# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.
# TYPE patroni_postgres_server_version gauge
patroni_postgres_server_version {scope="batman"} 140004
patroni_postgres_server_version{scope="batman",name="patroni1"} 140004
# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.
# TYPE patroni_cluster_unlocked gauge
patroni_cluster_unlocked{scope="batman"} 0
patroni_cluster_unlocked{scope="batman",name="patroni1"} 0
# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.
# TYPE patroni_postgres_timeline counter
patroni_postgres_timeline{scope="batman"} 24
patroni_failsafe_mode_is_active{scope="batman",name="patroni1"} 0
# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.
# TYPE patroni_postgres_timeline counter
patroni_postgres_timeline{scope="batman",name="patroni1"} 24
# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully by Patroni.
# TYPE patroni_dcs_last_seen gauge
patroni_dcs_last_seen{scope="batman"} 1677658321
patroni_dcs_last_seen{scope="batman",name="patroni1"} 1677658321
# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.
# TYPE patroni_pending_restart gauge
patroni_pending_restart{scope="batman"} 1
patroni_pending_restart{scope="batman",name="patroni1"} 1
# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.
# TYPE patroni_is_paused gauge
patroni_is_paused{scope="batman"} 1
patroni_is_paused{scope="batman",name="patroni1"} 1
Cluster status endpoints
@@ -192,24 +360,24 @@ Cluster status endpoints
{
"members": [
{
"name": "postgresql0",
"host": "127.0.0.1",
"port": 5432,
"name": "patroni1",
"role": "leader",
"state": "running",
"api_url": "http://127.0.0.1:8008/patroni",
"api_url": "http://10.89.0.4:8008/patroni",
"host": "10.89.0.4",
"port": 5432,
"timeline": 5,
"tags": {
"clonefrom": true
}
},
{
"name": "postgresql1",
"host": "127.0.0.1",
"port": 5433,
"name": "patroni2",
"role": "replica",
"state": "running",
"api_url": "http://127.0.0.1:8009/patroni",
"state": "streaming",
"api_url": "http://10.89.0.6:8008/patroni",
"host": "10.89.0.6",
"port": 5433,
"timeline": 5,
"tags": {
"clonefrom": true
@@ -217,9 +385,11 @@ Cluster status endpoints
"lag": 0
}
],
"scope": "demo",
"scheduled_switchover": {
"at": "2019-09-24T10:36:00+02:00",
"from": "postgresql0"
"at": "2023-09-24T10:36:00+02:00",
"from": "patroni1",
"to": "patroni3"
}
}
@@ -256,6 +426,7 @@ Cluster status endpoints
]
]
.. _config_endpoint:
Config endpoint
---------------
@@ -264,7 +435,7 @@ Config endpoint
.. code-block:: bash
$ curl -s localhost:8008/config | jq .
$ curl -s http://localhost:8008/config | jq .
{
"ttl": 30,
"loop_wait": 10,
@@ -275,7 +446,6 @@ Config endpoint
"use_pg_rewind": true,
"parameters": {
"hot_standby": "on",
"wal_log_hints": "on",
"wal_level": "hot_standby",
"max_wal_senders": 5,
"max_replication_slots": 5,
@@ -302,7 +472,6 @@ Config endpoint
"use_pg_rewind": true,
"parameters": {
"hot_standby": "on",
"wal_log_hints": "on",
"wal_level": "hot_standby",
"max_wal_senders": 5,
"max_replication_slots": 5,
@@ -326,8 +495,9 @@ Let's check that the node processed this configuration. First of all it should s
"location": 2197818976
},
"patroni": {
"version": "1.0",
"scope": "batman",
"version": "1.0"
"name": "patroni1"
},
"state": "running",
"role": "master",
@@ -355,7 +525,6 @@ If you want to remove (reset) some setting just patch it with ``null``:
"hot_standby": "on",
"unix_socket_directories": ".",
"wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5,
"max_replication_slots": 5
}
@@ -369,7 +538,7 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
.. code-block:: bash
$ curl -s -XPUT -d \
'{"maximum_lag_on_failover":1048576,"retry_timeout":10,"postgresql":{"use_slots":true,"use_pg_rewind":true,"parameters":{"hot_standby":"on","wal_log_hints":"on","wal_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
'{"maximum_lag_on_failover":1048576,"retry_timeout":10,"postgresql":{"use_slots":true,"use_pg_rewind":true,"parameters":{"hot_standby":"on","wal_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
http://localhost:8008/config | jq .
{
"ttl": 20,
@@ -381,7 +550,6 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
"hot_standby": "on",
"unix_socket_directories": ".",
"wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5
},
"use_pg_rewind": true
@@ -393,41 +561,114 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
Switchover and failover endpoints
---------------------------------
``POST /switchover`` or ``POST /failover``. These endpoints are very similar to each other. There are a couple of minor differences though:
.. _switchover_api:
1. The failover endpoint allows to perform a manual failover when there are no healthy nodes, but at the same time it will not allow you to schedule a switchover.
Switchover
^^^^^^^^^^
2. The switchover endpoint is the opposite. It works only when the cluster is healthy (there is a leader) and allows to schedule a switchover at a given time.
``/switchover`` endpoint only works when the cluster is healthy (there is a leader). It also allows to schedule a switchover at a given time.
When calling ``/switchover`` endpoint a candidate can be specified but is not required, in contrast to ``/failover`` endpoint. If a candidate is not provided, all the eligible nodes of the cluster will participate in the leader race after the leader stepped down.
In the JSON body of the ``POST`` request you must specify at least the ``leader`` or ``candidate`` fields and optionally the ``scheduled_at`` field if you want to schedule a switchover at a specific time.
In the JSON body of the ``POST`` request you must specify the ``leader`` field. The ``candidate`` and the ``scheduled_at`` fields are optional and can be used to schedule a switchover at a specific time.
Depending on the situation, requests might return different HTTP status codes and bodies. Status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412**, or **503**) will be returned with some details in the response body.
Example: perform a failover to the specific node:
``DELETE /switchover`` can be used to delete the currently scheduled switchover.
**Example:** perform a switchover to any healthy standby
.. code-block:: bash
$ curl -s http://localhost:8009/failover -XPOST -d '{"candidate":"postgresql1"}'
Successfully failed over to "postgresql1"
$ curl -s http://localhost:8008/switchover -XPOST -d '{"leader":"postgresql1"}'
Successfully switched over to "postgresql2"
Example: schedule a switchover from the leader to any other healthy replica in the cluster at a specific time:
**Example:** perform a switchover to a specific node
.. code-block:: bash
$ curl -s http://localhost:8008/switchover -XPOST -d \
'{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}'
Switchover scheduled
$ curl -s http://localhost:8008/switchover -XPOST -d \
'{"leader":"postgresql1","candidate":"postgresql2"}'
Successfully switched over to "postgresql2"
Depending on the situation the request might finish with a different HTTP status code and body. The status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412** or **503**) will be returned with some details in the response body. For more information please check the source code of ``patroni/api.py:do_POST_failover()`` method.
**Example:** schedule a switchover from the leader to any other healthy standby in the cluster at a specific time.
- ``DELETE /switchover``: delete the scheduled switchover
.. code-block:: bash
The ``POST /switchover`` and ``POST failover`` endpoints are used by ``patronictl switchover`` and ``patronictl failover``, respectively.
The ``DELETE /switchover`` is used by ``patronictl flush <cluster-name> switchover``.
$ curl -s http://localhost:8008/switchover -XPOST -d \
'{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}'
Switchover scheduled
Failover
^^^^^^^^
``/failover`` endpoint can be used to perform a manual failover when there are no healthy nodes (e.g. to an asynchronous standby if all synchronous standbys are not healthy enough to promote). However there is no requirement for a cluster not to have leader - failover can also be run on a healthy cluster.
In the JSON body of the ``POST`` request you must specify the ``candidate`` field. If the ``leader`` field is specified, a switchover is triggered instead.
**Example:**
.. code-block:: bash
$ curl -s http://localhost:8008/failover -XPOST -d '{"candidate":"postgresql1"}'
Successfully failed over to "postgresql1"
.. warning::
:ref:`Be very careful <failover_healthcheck>` when using this endpoint, as this can cause data loss in certain situations. In most cases, :ref:`the switchover endpoint <switchover_api>` satisfies the administrator's needs.
``POST /switchover`` and ``POST /failover`` endpoints are used by :ref:`patronictl_switchover` and :ref:`patronictl_failover`, respectively.
``DELETE /switchover`` is used by :ref:`patronictl flush cluster-name switchover <patronictl_flush_parameters>`.
.. list-table:: Failover/Switchover comparison
:widths: 25 25 25
:header-rows: 1
* -
- Failover
- Switchover
* - Requires leader specified
- no
- yes
* - Requires candidate specified
- yes
- no
* - Can be run in pause
- yes
- yes (only to a specific candidate)
* - Can be scheduled
- no
- yes (if not in pause)
.. _failover_healthcheck:
Healthy standby
^^^^^^^^^^^^^^^
There are a couple of checks that a member of a cluster should pass to be able to participate in the leader race during a switchover or to become a leader as a failover/switchover candidate:
- be reachable via Patroni API;
- not have ``nofailover`` tag set to ``true``;
- have watchdog fully functional (if required by the configuration);
- in case of a switchover in a healthy cluster or an automatic failover, not exceed maximum replication lag (``maximum_lag_on_failover`` :ref:`configuration parameter <dynamic_configuration>`);
- in case of a switchover in a healthy cluster or an automatic failover, not have a timeline number smaller than the cluster timeline if ``check_timeline`` :ref:`configuration parameter <dynamic_configuration>` is set to ``true``;
- in :ref:`synchronous mode <synchronous_mode>`:
- In case of a switchover (both with and without a candidate): be listed in the ``/sync`` key members;
- For a failover in both healthy and unhealthy clusters, this check is omitted.
.. warning::
In case of a manual failover in a cluster without a leader, a candidate will be allowed to promote even if:
- it is not in the ``/sync`` key members when synchronous mode is enabled;
- its lag exceeds the maximum replication lag allowed;
- it has the timeline number smaller than the last known cluster timeline.
.. _restart_endpoint:
Restart endpoint
----------------
@@ -441,15 +682,16 @@ Restart endpoint
- ``DELETE /restart``: delete the scheduled restart
``POST /restart`` and ``DELETE /restart`` endpoints are used by ``patronictl restart`` and ``patronictl flush <cluster-name> restart`` respectively.
``POST /restart`` and ``DELETE /restart`` endpoints are used by :ref:`patronictl_restart` and :ref:`patronictl flush cluster-name restart <patronictl_flush_parameters>` respectively.
.. _reload_endpoint:
Reload endpoint
---------------
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of ``patronictl restart``.
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of :ref:`patronictl_restart`.
The reload endpoint is used by ``patronictl reload``.
The reload endpoint is used by :ref:`patronictl_reload`.
Reinitialize endpoint
@@ -459,4 +701,4 @@ Reinitialize endpoint
The call might fail if Patroni is in a loop trying to recover (restart) a failed Postgres. In order to overcome this problem one can specify ``{"force":true}`` in the request body.
The reinitialize endpoint is used by ``patronictl reinit``.
The reinitialize endpoint is used by :ref:`patronictl_reinit`.
+3 -3
View File
@@ -9,7 +9,7 @@ A Patroni cluster has two interfaces to be protected from unauthorized access: t
Protecting DCS
==============
Patroni and patronictl both store and retrieve data to/from the DCS.
Patroni and :ref:`patronictl` both store and retrieve data to/from the DCS.
Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
@@ -22,7 +22,7 @@ Protecting the REST API
Protecting the REST API is a more complicated task.
The Patroni REST API is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
The Patroni REST API is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
From the point of view of security, REST API contains safe (``GET`` requests, only retrieve information) and unsafe (``PUT``, ``POST``, ``PATCH`` and ``DELETE`` requests, change the state of nodes) endpoints.
@@ -32,6 +32,6 @@ When TLS for the REST API is enabled and a PKI is established, mutual authentica
The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``).
The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
The ``ctl`` section parameters enable TLS server authentication to the client (the :ref:`patronictl` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html
+78
View File
@@ -0,0 +1,78 @@
.. _standby_cluster:
Standby cluster
---------------
Patroni also support running cascading replication to a remote datacenter
(region) using a feature that is called "standby cluster". This type of
clusters has:
* "standby leader", that behaves pretty much like a regular cluster leader,
except it replicates from a remote node.
* cascade replicas, that are replicating from standby leader.
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 :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
primary cluster.
For the sake of flexibility, you can specify methods of creating a replica and
recovery WAL records when a cluster is in the "standby mode" by providing
:ref:`create_replica_methods <custom_replica_creation>` key in
`standby_cluster` section. It is distinct from creating replicas, when cluster
is detached and functions as a normal cluster, which is controlled by
`create_replica_methods` in `postgresql` section. Both "standby" and "normal"
`create_replica_methods` reference keys in `postgresql` section.
To configure such cluster you need to specify the section ``standby_cluster``
in a patroni configuration:
.. code:: YAML
bootstrap:
dcs:
standby_cluster:
host: 1.2.3.4
port: 5432
primary_slot_name: patroni
create_replica_methods:
- basebackup
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.
In case the remote site doesn't provide a single endpoint that connects to a
primary, one could list all hosts of the source cluster in the
``standby_cluster.host`` section. When ``standby_cluster.host`` contains
multiple hosts separated by commas, Patroni will:
* add ``target_session_attrs=read-write`` to the ``primary_conninfo`` on the
standby leader node.
* use ``target_session_attrs=read-write`` when trying to determine whether we
need to run ``pg_rewind`` or when executing ``pg_rewind`` on all nodes of the
standby cluster.
There is also a possibility to replicate the standby cluster from another
standby cluster or from a standby member of the primary cluster: for that, you
need to define a single host in the ``standby_cluster.host`` section. However,
you need to beware that in this case ``pg_rewind`` will fail to execute on the
standby cluster.
+64
View File
@@ -0,0 +1,64 @@
.. _tools_integration:
Integration with other tools
============================
Patroni is able to integrate with other tools in your stack. In this section you
will find a list of examples, which although not an exhaustive list, might
provide you with ideas on how Patroni can integrate with other tools.
Barman
------
Patroni delivers an application named ``patroni_barman`` which has logic to
communicate with ``pg-backup-api``, so you are able to perform Barman operations
remotely.
This application currently has a couple of sub-commands: ``recover`` and
``config-switch``.
patroni_barman recover
^^^^^^^^^^^^^^^^^^^^^^
The ``recover`` sub-command can be used as a custom bootstrap or custom replica
creation method. You can find more information about that in
:ref:`replica_imaging_and_bootstrap`.
patroni_barman config-switch
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The ``config-switch`` sub-command is designed to be used as an ``on_role_change``
callback in Patroni. As an example, assume you are streaming WALs from your
current primary to your Barman host. In the event of a failover in the cluster
you might want to start streaming WALs from the new primary. You can accomplish
this by using ``patroni_barman config-switch`` as the ``on_role_change`` callback.
.. note::
That sub-command relies on the ``barman config-switch`` command, which is in
charge of overriding the configuration of a Barman server by applying a
pre-defined model on top of it. This command is available since Barman 3.10.
Please consult the Barman documentation for more details.
This is an example of how you can configure Patroni to apply a configuration
model in case this Patroni node is promoted to primary:
.. code:: YAML
postgresql:
callbacks:
on_role_change: >
patroni_barman
--api-url YOUR_API_URL
config-switch
--barman-server YOUR_BARMAN_SERVER_NAME
--barman-model YOUR_BARMAN_MODEL_NAME
--switch-when promoted
.. note::
``patroni_barman config-switch`` requires that you have both Barman and
``pg-backup-api`` configured in the Barman host, so it can execute a remote
``barman config-switch`` through the backup API. Also, it requires that you
have pre-configured Barman models to be applied. The above example uses a
subset of the available parameters. You can get more information running
``patroni_barman config-switch --help``, and by consulting the Barman
documentation.
+43 -23
View File
@@ -11,12 +11,22 @@ Global/Universal
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
.. _log_settings:
Log
---
- **type**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **format**: sets the log formatting string. If the log type is **plain**, the log format should be a string. Refer to
`the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
Default value is **%(asctime)s %(levelname)s: %(message)s**
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **static_fields**: add additional fields to the log. This option is only available when the log type is set to **json**.
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
@@ -26,13 +36,33 @@ Log
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
Here is an example of how to config patroni to log in json format.
.. code:: YAML
log:
type: json
format:
- message
- module
- asctime: '@timestamp'
- levelname: level
static_fields:
app: patroni
.. _bootstrap_settings:
Bootstrap configuration
-----------------------
.. note::
Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future
changes to the ``bootstrap.dcs`` section of the YAML configuration will not take any effect! If you want to change
them please use either :ref:`patronictl_edit_config` or the Patroni :ref:`REST API <rest_api>`.
- **bootstrap**:
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing of new cluster. The global dynamic configuration for the cluster. Under the ``bootstrap.dcs`` you can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration>` and after Patroni initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store. All later changes of ``bootstrap.dcs`` will not take any effect! If you want to change them please use either ``patronictl edit-config`` or Patroni :ref:`REST API <rest_api>`.
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing the new cluster. The global dynamic configuration for the cluster. You can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration>` under ``bootstrap.dcs`` and after Patroni has initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store.
- **method**: custom script to use for bootstrapping this cluster.
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
@@ -43,24 +73,8 @@ Bootstrap configuration
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **users**: Some additional users which need to be created after initializing new cluster, see :ref:`Bootstrap users configuration <bootstrap_users_configuration>` below.
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _bootstrap_users_configuration:
Bootstrap users configuration
=============================
Users which need to be created after initializing the cluster:
- **admin**: the name of user
- **password**: (optional) password for the user
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
.. _citus_settings:
Citus
@@ -143,6 +157,7 @@ ZooKeeper
- **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]}``.
- **auth_data**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -360,10 +375,10 @@ CTL
- **authentication**:
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format.
- **keyfile**: Specifies the file with the client secret key in the PEM format.
- **keyfile\_password**: Specifies a password for decrypting the client keyfile.
@@ -378,11 +393,16 @@ Watchdog
Tags
----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
- **nostream**: ``true`` or ``false``. If set to ``true`` the node will not use replication protocol to stream WAL. It will rely instead on archive recovery (if ``restore_command`` is configured) and ``pg_wal``/``pg_xlog`` polling. It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas. Setting this tag on primary node has no effect.
.. warning::
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
In addition to these predefined tags, you can also add your own ones:
@@ -391,4 +411,4 @@ In addition to these predefined tags, you can also add your own ones:
- **key3**: ``1.4``
- **key4**: ``"RandomString"``
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
Tags are visible in the :ref:`REST API <rest_api>` and :ref:`patronictl_list` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
+5 -2
View File
@@ -27,8 +27,8 @@ RUN set -ex \
&& apt-get update \
&& apt-get reinstall init-system-helpers \
&& apt-get install -y \
python3-pip \
python3-dev \
python3-venv \
rsync \
curl \
gcc \
@@ -40,7 +40,9 @@ RUN set -ex \
net-tools \
iputils-ping \
&& rm -rf /var/cache/apt \
&& python3 -m pip install --no-cache-dir tox \
\
&& python3 -m venv /tox \
&& /tox/bin/pip install --no-cache-dir tox>=4 \
\
&& mkdir -p "$PGHOME" \
&& sed -i "s|/var/lib/postgresql.*|$PGHOME:/bin/bash|" /etc/passwd \
@@ -50,6 +52,7 @@ RUN set -ex \
&& curl -sL "$ETCDURL/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
ENV PATH="/tox/bin:$PATH"
# This Dockerfile syntax only works with docker buildx and the syntax
# line at the top of this file.
+1
View File
@@ -6,6 +6,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--datadir", required=True)
parser.add_argument("--sourcedir", required=True)
parser.add_argument("--test-argument", required=True)
args, _ = parser.parse_known_args()
shutil.copytree(args.sourcedir, args.datadir)
+1 -1
View File
@@ -82,4 +82,4 @@ Feature: basic replication
@reject-duplicate-name
Scenario: check graceful rejection when two nodes have the same name
Given I start duplicate postgres0 on port 8011
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds
+8 -1
View File
@@ -11,7 +11,9 @@ Feature: citus
Then replication works from postgres0 to postgres1 after 15 seconds
Then replication works from postgres2 to postgres3 after 15 seconds
And postgres0 is registered in the postgres0 as the primary in group 0 after 5 seconds
And postgres1 is registered in the postgres0 as the secondary in group 0 after 5 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres3 is registered in the postgres0 as the secondary in group 1 after 5 seconds
Scenario: coordinator failover updates pg_dist_node
Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force
@@ -19,11 +21,13 @@ Feature: citus
And "members/postgres0" key in a group 0 in DCS has state=running after 15 seconds
And replication works from postgres1 to postgres0 after 15 seconds
And postgres1 is registered in the postgres2 as the primary in group 0 after 5 seconds
And postgres0 is registered in the postgres2 as the secondary in group 0 after 15 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds
When I run patronictl.py switchover batman --group 0 --candidate postgres0 --force
Then postgres0 role is the primary after 10 seconds
And replication works from postgres0 to postgres1 after 15 seconds
And postgres0 is registered in the postgres2 as the primary in group 0 after 5 seconds
And postgres1 is registered in the postgres2 as the secondary in group 0 after 15 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds
Scenario: worker switchover doesn't break client queries on the coordinator
@@ -35,6 +39,7 @@ Feature: citus
And "members/postgres2" key in a group 1 in DCS has state=running after 15 seconds
And replication works from postgres3 to postgres2 after 15 seconds
And postgres3 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres2 is registered in the postgres0 as the secondary in group 1 after 15 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds
And a thread is still alive
When I run patronictl.py switchover batman --group 1 --force
@@ -42,6 +47,7 @@ Feature: citus
And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres3 is registered in the postgres0 as the secondary in group 1 after 15 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds
And a thread is still alive
When I stop a thread
@@ -55,6 +61,7 @@ Feature: citus
And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres3 is registered in the postgres0 as the secondary in group 1 after 15 seconds
And a thread is still alive
When I stop a thread
Then a distributed table on postgres0 has expected rows
@@ -68,6 +75,6 @@ Feature: citus
And I receive a response output "+ttl: 20"
Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds
When I shut down postgres4
Then There is a transaction in progress on postgres0 changing pg_dist_node
Then there is a transaction in progress on postgres0 changing pg_dist_node after 5 seconds
When I run patronictl.py restart batman postgres2 --group 1 --force
Then a transaction finishes in 20 seconds
+42 -11
View File
@@ -4,14 +4,14 @@ Feature: dcs failsafe mode
Scenario: check failsafe mode can be successfully enabled
Given I start postgres0
And postgres0 is a leader after 10 seconds
And I sleep for 3 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 5, "failsafe_mode": true}
Then "config" key in DCS has ttl=30 after 10 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 3, "failsafe_mode": true}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/failsafe contains postgres0 after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/failsafe
Then I receive a response code 200
And I receive a response postgres0 http://127.0.0.1:8008/patroni
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}}
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}},"slots":{"dcs_slot_1": null,"postgres0":null}}
Then I receive a response code 200
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots": {"dcs_slot_0": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
Then I receive a response code 200
@@ -28,7 +28,6 @@ Feature: dcs failsafe mode
When I do a backup of postgres0
And I shut down postgres0
When I start postgres1 in a cluster batman from backup with no_leader
And I sleep for 2 seconds
Then postgres1 role is the replica after 12 seconds
Scenario: check leader and replica are both in /failsafe key after leader is back
@@ -45,41 +44,73 @@ Feature: dcs failsafe mode
@dcs-failsafe
@slot-advance
Scenario: check leader and replica are functioning while DCS is down
Given logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
Given I get all changes from physical slot dcs_slot_1 on postgres0
Then physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
And DCS is down
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
Then postgres0 role is the primary after 10 seconds
And postgres1 role is the replica after 2 seconds
And replication works from postgres0 to postgres1 after 10 seconds
And I get all changes from logical slot dcs_slot_0 on postgres0
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
When I get all changes from logical slot dcs_slot_0 on postgres0
And I get all changes from physical slot dcs_slot_1 on postgres0
Then logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
And physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
@dcs-failsafe
Scenario: check primary is demoted when one replica is shut down and DCS is down
Given DCS is down
And I kill postgres1
And I kill postmaster on postgres1
And I sleep for 2 seconds
Then postgres0 role is the replica after 12 seconds
@dcs-failsafe
Scenario: check known replica is promoted when leader is down and DCS is up
Given I shut down postgres0
Given I kill postgres0
And I shut down postmaster on postgres0
And DCS is up
When I start postgres1
Then "members/postgres1" key in DCS has state=running after 10 seconds
And postgres1 role is the primary after 25 seconds
@dcs-failsafe
Scenario: check three-node cluster is functioning while DCS is down
Scenario: scale to three-node cluster
Given I start postgres0
And I start postgres2
Then "members/postgres2" key in DCS has state=running after 10 seconds
And "members/postgres0" key in DCS has state=running after 20 seconds
And Response on GET http://127.0.0.1:8008/failsafe contains postgres2 after 10 seconds
And replication works from postgres1 to postgres0 after 10 seconds
And replication works from postgres1 to postgres2 after 10 seconds
@dcs-failsafe
@slot-advance
Scenario: make sure permanent slots exist on replicas
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
When I get all changes from physical slot dcs_slot_1 on postgres1
Then physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
@dcs-failsafe
Scenario: check three-node cluster is functioning while DCS is down
Given DCS is down
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
Then Response on GET http://127.0.0.1:8009/primary contains failsafe_mode_is_active after 12 seconds
Then postgres1 role is the primary after 10 seconds
And postgres0 role is the replica after 2 seconds
And postgres2 role is the replica after 2 seconds
@dcs-failsafe
@slot-advance
Scenario: check that permanent slots are in sync between nodes while DCS is down
Given replication works from postgres1 to postgres0 after 10 seconds
And replication works from postgres1 to postgres2 after 10 seconds
When I get all changes from logical slot dcs_slot_2 on postgres1
And I get all changes from physical slot dcs_slot_1 on postgres1
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
And physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
+37 -24
View File
@@ -162,9 +162,10 @@ class PatroniController(AbstractController):
def stop(self, kill=False, timeout=15, postgres=False):
if postgres:
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w'])
mode = 'i' if kill else 'f'
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-m' + mode, '-w'])
super(PatroniController, self).stop(kill, timeout)
if isinstance(self._context.dcs_ctl, KubernetesController):
if isinstance(self._context.dcs_ctl, KubernetesController) and not kill:
self._context.dcs_ctl.delete_pod(self._name[8:])
if self.watchdog:
self.watchdog.stop()
@@ -244,6 +245,10 @@ class PatroniController(AbstractController):
self.recursive_update(config, custom_config)
self.recursive_update(config, {
'log': {
'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)d - %(funcName)s]: %(message)s',
'loggers': {'patroni.postgresql.callback_executor': 'DEBUG'}
},
'bootstrap': {
'dcs': {
'loop_wait': 2,
@@ -251,10 +256,18 @@ class PatroniController(AbstractController):
'parameters': {
'wal_keep_segments': 100,
'archive_mode': 'on',
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode archive '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data', 'wal_archive'))
'archive_command':
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode archive '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data',
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/'),
'restore_command':
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode restore '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data',
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/')
}
}
}
@@ -649,9 +662,10 @@ class KubernetesController(AbstractExternalDcsController):
try:
if group is not None:
scope = '{0}-{1}'.format(scope, group)
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(key, '-' + key)
rkey = 'leader' if key in ('status', 'failsafe') else key
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(rkey, '-' + rkey)
e = self._api.read_namespaced_endpoints(ep, self._namespace)
if key != 'sync':
if key not in ('sync', 'status', 'failsafe'):
return e.metadata.annotations[key]
else:
return json.dumps(e.metadata.annotations)
@@ -687,7 +701,7 @@ class ZooKeeperController(AbstractExternalDcsController):
self._client = kazoo.client.KazooClient()
def process_name(self):
return "zookeeper"
return "java .*zookeeper"
def query(self, key, scope='batman', group=None):
import kazoo.exceptions
@@ -886,22 +900,28 @@ class PatroniPoolController(object):
}
self.start(to_name, custom_config=custom_config)
def backup_restore_config(self, params=None):
return {
'command': (self.BACKUP_RESTORE_SCRIPT
+ ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')).replace('\\', '/'),
'test-argument': 'test-value', # test config mapping approach on custom bootstrap/replica creation
**(params or {}),
}
def bootstrap_from_backup(self, name, cluster_name):
custom_config = {
'scope': cluster_name,
'bootstrap': {
'method': 'backup_restore',
'backup_restore': {
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
+ os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'backup_restore': self.backup_restore_config({
'recovery_conf': {
'recovery_target_action': 'promote',
'recovery_target_timeline': 'latest',
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
}
}
},
})
},
'postgresql': {
'authentication': {
@@ -916,17 +936,8 @@ class PatroniPoolController(object):
custom_config = {
'scope': cluster_name,
'postgresql': {
'recovery_conf': {
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
+ '--dirname {} --filename %f --pathname %p')
.format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
},
'create_replica_methods': ['no_leader_bootstrap'],
'no_leader_bootstrap': {
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
+ os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'no_leader': '1'
}
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
}
}
self.start(name, custom_config=custom_config)
@@ -1065,6 +1076,8 @@ def before_all(context):
context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key')
context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt')
try:
if sys.platform == 'darwin' and 'GITHUB_ACTIONS' in os.environ:
raise Exception
with open(os.devnull, 'w') as null:
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
'-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', context.keyfile,
+13 -13
View File
@@ -25,10 +25,10 @@ Feature: ignored slots
# but Patroni can actually end up dropping them almost immediately, so it's helpful
# to verify they exist before we begin testing whether they persist through failover
# cycles.
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
When I start postgres0
Then "members/postgres0" key in DCS has role=replica after 10 seconds
@@ -46,16 +46,16 @@ Feature: ignored slots
And "members/postgres1" key in DCS has role=replica after 10 seconds
# give Patroni time to sync replication slots
And I sleep for 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
And postgres1 does not have a logical replication slot named dummy_slot
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
And postgres1 does not have a replication slot named dummy_slot
# 3. After a failover the server (now a primary) still has the slot.
When I shut down postgres0
Then "members/postgres1" key in DCS has role=master after 10 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
+18
View File
@@ -0,0 +1,18 @@
Feature: nostream node
Scenario: check nostream node is recovering from archive
When I start postgres0
And I configure and start postgres1 with a tag nostream true
Then "members/postgres1" key in DCS has replication_state=in archive recovery after 10 seconds
And replication works from postgres0 to postgres1 after 30 seconds
@slot-advance
Scenario: check permanent logical replication slots are not copied
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}, "slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
Then I receive a response code 200
When I run patronictl.py restart batman postgres0 --force
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
When I configure and start postgres2 with a tag replicatefrom postgres1
Then "members/postgres2" key in DCS has replication_state=streaming after 10 seconds
And postgres1 does not have a replication slot named test_logical
And postgres2 does not have a replication slot named test_logical
+2 -1
View File
@@ -68,6 +68,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
When I kill postmaster on postgres1
And I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 503
And "members/postgres1" key in DCS has state=stopped after 10 seconds
When I run patronictl.py restart batman postgres1 --force
Then I receive a response returncode 0
Then replication works from postgres0 to postgres1 after 20 seconds
@@ -76,7 +77,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
Then I receive a response code 200
And I receive a response state running
And I receive a response role replica
When I run patronictl.py reinit batman postgres1 --force
When I run patronictl.py reinit batman postgres1 --force --wait
Then I receive a response returncode 0
And I receive a response output "Success: reinitialize for member postgres1"
And postgres1 role is the secondary after 30 seconds
+75
View File
@@ -0,0 +1,75 @@
Feature: permanent slots
Scenario: check that physical permanent slots are created
Given I start postgres0
Then postgres0 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_physical":0,"postgres0":0,"postgres1":0,"postgres3":0},"postgresql":{"parameters":{"wal_level":"logical"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/config contains slots after 10 seconds
When I start postgres1
And I start postgres2
And I configure and start postgres3 with a tag replicatefrom postgres2
Then postgres0 has a physical replication slot named test_physical after 10 seconds
And postgres0 has a physical replication slot named postgres1 after 10 seconds
And postgres0 has a physical replication slot named postgres2 after 10 seconds
And postgres2 has a physical replication slot named postgres3 after 10 seconds
@slot-advance
Scenario: check that logical permanent slots are created
Given I run patronictl.py restart batman postgres0 --force
And I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
@slot-advance
Scenario: check that permanent slots are created on replicas
Given postgres1 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
And postgres1 has a physical replication slot named test_physical after 2 seconds
And postgres2 has a physical replication slot named test_physical after 2 seconds
And postgres3 has a physical replication slot named test_physical after 2 seconds
@slot-advance
Scenario: check permanent physical slots that match with member names
Given postgres0 has a physical replication slot named postgres3 after 2 seconds
And postgres1 has a physical replication slot named postgres0 after 2 seconds
And postgres1 has a physical replication slot named postgres3 after 2 seconds
And postgres2 has a physical replication slot named postgres0 after 2 seconds
And postgres2 has a physical replication slot named postgres3 after 2 seconds
And postgres2 has a physical replication slot named postgres1 after 2 seconds
And postgres1 does not have a replication slot named postgres2
And postgres3 does not have a replication slot named postgres2
@slot-advance
Scenario: check that permanent slots are advanced on replicas
Given I add the table replicate_me to postgres0
When I get all changes from logical slot test_logical on postgres0
And I get all changes from physical slot test_physical on postgres0
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
And Physical slot test_physical is in sync between postgres0 and postgres1 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
And Physical slot test_physical is in sync between postgres0 and postgres2 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
And Physical slot test_physical is in sync between postgres0 and postgres3 after 10 seconds
And Physical slot postgres1 is in sync between postgres0 and postgres2 after 10 seconds
And Physical slot postgres3 is in sync between postgres2 and postgres0 after 20 seconds
And Physical slot postgres3 is in sync between postgres2 and postgres1 after 10 seconds
And postgres1 does not have a replication slot named postgres2
And postgres3 does not have a replication slot named postgres2
@slot-advance
Scenario: check that only permanent slots are written to the /status key
Given "status" key in DCS has test_physical in slots
And "status" key in DCS has postgres0 in slots
And "status" key in DCS has postgres1 in slots
And "status" key in DCS does not have postgres2 in slots
And "status" key in DCS has postgres3 in slots
Scenario: check permanent physical replication slot after failover
Given I shut down postgres3
And I shut down postgres2
And I shut down postgres0
Then postgres1 has a physical replication slot named test_physical after 10 seconds
And postgres1 has a physical replication slot named postgres0 after 10 seconds
And postgres1 has a physical replication slot named postgres3 after 10 seconds
+39
View File
@@ -0,0 +1,39 @@
Feature: priority replication
We should check that we can give nodes priority during failover
Scenario: check failover priority 0 prevents leaderships
Given I configure and start postgres0 with a tag failover_priority 1
And I configure and start postgres1 with a tag failover_priority 0
Then replication works from postgres0 to postgres1 after 20 seconds
When I shut down postgres0
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
Then postgres1 role is the secondary after 10 seconds
When I start postgres0
Then postgres0 role is the primary after 10 seconds
Scenario: check higher failover priority is respected
Given I configure and start postgres2 with a tag failover_priority 1
And I configure and start postgres3 with a tag failover_priority 2
Then replication works from postgres0 to postgres2 after 20 seconds
And replication works from postgres0 to postgres3 after 20 seconds
When I shut down postgres0
Then postgres3 role is the primary after 10 seconds
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
Scenario: check conflicting configuration handling
When I set nofailover tag in postgres2 config
And I issue an empty POST request to http://127.0.0.1:8010/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: True and failover_priority: 1. Defaulting to nofailover: True"] WARNING in the postgres2 patroni log after 5 seconds
And "members/postgres2" key in DCS has tags={'failover_priority': '1', 'nofailover': True} after 10 seconds
When I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres2"}
Then I receive a response code 412
And I receive a response text "failover is not possible: no good candidates have been found"
When I reset nofailover tag in postgres1 config
And I issue an empty POST request to http://127.0.0.1:8009/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: False and failover_priority: 0. Defaulting to nofailover: False"] WARNING in the postgres1 patroni log after 5 seconds
And "members/postgres1" key in DCS has tags={'failover_priority': '0', 'nofailover': False} after 10 seconds
And I issue a POST request to http://127.0.0.1:8009/failover with {"candidate": "postgres1"}
Then I receive a response code 200
And postgres1 role is the primary after 10 seconds
+2
View File
@@ -14,6 +14,8 @@ Feature: recovery
Then I receive a response code 200
And I receive a response role master
And I receive a response timeline 1
And "members/postgres0" key in DCS has state=running after 12 seconds
And replication works from postgres0 to postgres1 after 15 seconds
Scenario: check immediate failover when master_start_timeout=0
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"master_start_timeout": 0}
+16 -14
View File
@@ -22,14 +22,11 @@ Feature: standby cluster
Scenario: check permanent logical slots are synced to the replica
Given I run patronictl.py restart batman postgres1 --force
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
When I add the table replicate_me to postgres1
And I get all changes from logical slot test_logical on postgres1
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
Scenario: Detach exiting node from the cluster
When I shut down postgres1
Then postgres0 is a leader after 10 seconds
And "members/postgres0" key in DCS has role=master after 3 seconds
And "members/postgres0" key in DCS has role=master after 5 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
@@ -50,21 +47,26 @@ Feature: standby cluster
And there is a postgres1_cb.log with "on_role_change standby_leader batman1" in postgres1 data directory
When I start postgres2 in a cluster batman1
Then postgres2 role is the replica after 24 seconds
And postgres2 is replicating from postgres1 after 10 seconds
And table foo is present on postgres2 after 20 seconds
When I issue a GET request to http://127.0.0.1:8010/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And postgres1 does not have a logical replication slot named test_logical
And postgres1 does not have a replication slot named test_logical
Scenario: check switchover
Given I run patronictl.py switchover batman1 --force
Then Status code on GET http://127.0.0.1:8010/standby_leader is 200 after 10 seconds
And postgres1 is replicating from postgres2 after 32 seconds
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
Scenario: check failover
When I kill postgres1
And I kill postmaster on postgres1
Then postgres2 is replicating from postgres0 after 32 seconds
When I issue a GET request to http://127.0.0.1:8010/primary
When I kill postgres2
And I kill postmaster on postgres2
Then postgres1 is replicating from postgres0 after 32 seconds
And Status code on GET http://127.0.0.1:8009/standby_leader is 200 after 10 seconds
When I issue a GET request to http://127.0.0.1:8009/primary
Then I receive a response code 503
And I sleep for 3 seconds
When I issue a GET request to http://127.0.0.1:8010/standby_leader
Then I receive a response code 200
And I receive a response role standby_leader
And replication works from postgres0 to postgres2 after 15 seconds
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
And replication works from postgres0 to postgres1 after 15 seconds
And there is a postgres1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres1 data directory
+27 -10
View File
@@ -1,3 +1,4 @@
import json
import patroni.psycopg as pg
from behave import step, then
@@ -35,16 +36,27 @@ def kill_patroni(context, name):
return context.pctl.stop(name, kill=True)
@step('I kill postmaster on {name:w}')
@step('I shut down postmaster on {name:w}')
def stop_postgres(context, name):
return context.pctl.stop(name, postgres=True)
@step('I kill postmaster on {name:w}')
def kill_postgres(context, name):
return context.pctl.stop(name, kill=True, postgres=True)
def get_wal_name(context, pg_name):
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
return 'xlog' if int(version) / 10000 < 10 else 'wal'
@step('I add the table {table_name:w} to {pg_name:w}')
def add_table(context, table_name, pg_name):
# parse the configuration file and get the port
try:
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name))
context.pctl.query(pg_name, "SELECT pg_switch_{0}()".format(get_wal_name(context, pg_name)))
except pg.Error as e:
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
@@ -53,9 +65,7 @@ def add_table(context, table_name, pg_name):
def toggle_wal_replay(context, action, pg_name):
# pause or resume the wal replay process
try:
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
wal_name = 'xlog' if int(version) / 10000 < 10 else 'wal'
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(get_wal_name(context, pg_name), action))
except pg.Error as e:
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
@@ -105,11 +115,18 @@ def replication_works(context, primary, replica, time_limit):
context.execute_steps(u"""
When I add the table test_{0} to {1}
Then table test_{0} is present on {2} after {3} seconds
""".format(int(time()), primary, replica, time_limit))
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
@then('there is a "{message}" {level:w} in the {node} patroni log')
def check_patroni_log(context, message, level, node):
messsages_of_level = context.pctl.read_patroni_log(node, level)
assert any(message in line for line in messsages_of_level), \
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
@step('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
def check_patroni_log(context, message_list, level, node, timeout):
timeout *= context.timeout_multiplier
message_list = json.loads(message_list)
for _ in range(int(timeout)):
messsages_of_level = context.pctl.read_patroni_log(node, level)
if any(any(message in line for line in messsages_of_level) for message in message_list):
break
sleep(1)
else:
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
+1 -1
View File
@@ -28,7 +28,7 @@ def check_member(context, name, key, value, time_limit):
while time.time() < max_time:
try:
response = json.loads(context.dcs_ctl.query(name))
dcs_value = response.get(key)
dcs_value = str(response.get(key))
if dcs_value == value:
return
except Exception:
+13 -7
View File
@@ -115,15 +115,21 @@ def count_rows(context, name):
assert rows == context.insert_counter, "Distributed table doesn't have expected amount of rows"
@step("There is a transaction in progress on {name:w} changing pg_dist_node")
def check_transaction(context, name):
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
assert cur.rowcount == 1, "There is no idle in transaction updating pg_dist_node"
context.xact_start = cur.fetchone()[0]
@step("there is a transaction in progress on {name:w} changing pg_dist_node after {time_limit:d} seconds")
def check_transaction(context, name, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
if cur.rowcount == 1:
context.xact_start = cur.fetchone()[0]
return
time.sleep(1)
assert False, f"There is no idle in transaction on {name} updating pg_dist_node after {time_limit} seconds"
@step("a transaction finishes in {timeout:d} seconds")
def check_transaction_timeout(context, timeout):
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \
assert (datetime.now(tzutc) - context.xact_start).seconds >= timeout, \
"a transaction finished earlier than in {0} seconds".format(timeout)
+6
View File
@@ -128,6 +128,12 @@ def scheduled_restart(context, url, in_seconds, data):
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
@step('I {action:w} {tag:w} tag in {pg_name:w} config')
def add_bool_tag_to_config(context, action, tag, pg_name):
value = action == 'set'
context.pctl.add_tag_to_config(pg_name, tag, value)
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
def add_tag_to_config(context, tag, value, pg_name):
context.pctl.add_tag_to_config(pg_name, tag, value)
+62 -17
View File
@@ -1,3 +1,4 @@
import json
import time
from behave import step, then
@@ -15,21 +16,30 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin):
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@then('{pg_name:w} has a logical replication slot named {slot_name} with the {plugin:w} plugin')
def has_logical_replication_slot(context, pg_name, slot_name, plugin):
try:
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
assert row, "Couldn't find replication slot named {0}".format(slot_name)
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name)
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
"{1} rather than {2}").format(slot_name, row[1], plugin)
except pg.Error:
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@step('{pg_name:w} has a logical replication slot named {slot_name}'
' with the {plugin:w} plugin after {time_limit:d} seconds')
@then('{pg_name:w} has a logical replication slot named {slot_name}'
' with the {plugin:w} plugin after {time_limit:d} seconds')
def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
try:
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
f" WHERE slot_name = '{slot_name}'")).fetchone()
if row:
assert row[0] == "logical", f"Replication slot {slot_name} isn't a logical but {row[0]}"
assert row[1] == plugin, f"Replication slot {slot_name} using plugin {row[1]} rather than {plugin}"
return
except Exception:
pass
time.sleep(1)
assert False, f"Error looking for slot {slot_name} on {pg_name} with plugin {plugin}"
@then('{pg_name:w} does not have a logical replication slot named {slot_name}')
def does_not_have_logical_replication_slot(context, pg_name, slot_name):
@step('{pg_name:w} does not have a replication slot named {slot_name:w}')
@then('{pg_name:w} does not have a replication slot named {slot_name:w}')
def does_not_have_replication_slot(context, pg_name, slot_name):
try:
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
@@ -38,13 +48,14 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name):
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
@step('{slot_type:w} slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
column = 'confirmed_flush_lsn' if slot_type.lower() == 'logical' else 'restart_lsn'
query = f"SELECT {column} FROM pg_replication_slots WHERE slot_name = '{slot_name}'"
while time.time() < max_time:
try:
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
slot1 = context.pctl.query(pg_name1, query).fetchone()
slot2 = context.pctl.query(pg_name2, query).fetchone()
if slot1[0] == slot2[0]:
@@ -52,9 +63,43 @@ def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
except Exception:
pass
time.sleep(1)
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
assert False, \
f"{slot_type} slot {slot_name} is not in sync between {pg_name1} and {pg_name2} after {time_limit} seconds"
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
def logical_slot_get_changes(context, slot_name, pg_name):
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
@step('I get all changes from physical slot {slot_name:w} on {pg_name:w}')
def physical_slot_get_changes(context, slot_name, pg_name):
context.pctl.query(pg_name, f"SELECT * FROM pg_replication_slot_advance('{slot_name}', pg_current_wal_lsn())")
@step('{pg_name:w} has a physical replication slot named {slot_name} after {time_limit:d} seconds')
def has_physical_replication_slot(context, pg_name, slot_name, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
query = f"SELECT * FROM pg_catalog.pg_replication_slots WHERE slot_type = 'physical' AND slot_name = '{slot_name}'"
while time.time() < max_time:
try:
row = context.pctl.query(pg_name, query).fetchone()
if row:
return
except Exception:
pass
time.sleep(1)
assert False, f"Physical slot {slot_name} doesn't exist after {time_limit} seconds"
@step('"{name}" key in DCS has {subkey:w} in {key:w}')
def dcs_key_contains(context, name, subkey, key):
response = json.loads(context.dcs_ctl.query(name))
assert key in response and subkey in response[key], f"{name} key in DCS doesn't have {subkey} in {key}"
@step('"{name}" key in DCS does not have {subkey:w} in {key:w}')
def dcs_key_does_not_contain(context, name, subkey, key):
response = json.loads(context.dcs_ctl.query(name))
assert key not in response or subkey not in response[key], f"{name} key in DCS has {subkey} in {key}"
+2 -3
View File
@@ -15,9 +15,7 @@ def start_patroni(context, name, cluster_name):
"scope": cluster_name,
"postgresql": {
"callbacks": callbacks(context, name),
"backup_restore": {
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir="
+ os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
"backup_restore": context.pctl.backup_restore_config()
}
})
@@ -34,6 +32,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
"ttl": 20,
"loop_wait": 2,
"retry_timeout": 5,
"synchronous_mode": True, # should be completely ignored
"standby_cluster": {
"host": "localhost",
"port": port,
+3 -3
View File
@@ -1,4 +1,4 @@
FROM postgres:15
FROM postgres:16
LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \
@@ -9,8 +9,8 @@ RUN export DEBIAN_FRONTEND=noninteractive \
| xargs apt-get install -y vim-tiny curl jq locales git python3-pip python3-wheel \
## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& pip3 install setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& pip3 install --break-system-packages setuptools \
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \
&& mkdir -p $PGHOME \
&& chown postgres $PGHOME \
+22 -7
View File
@@ -1,4 +1,4 @@
FROM postgres:15
FROM postgres:16
LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \
@@ -10,12 +10,24 @@ RUN export DEBIAN_FRONTEND=noninteractive \
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \
## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3 \
&& pip3 install setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
apt-get install -y postgresql-server-dev-16 \
gcc make autoconf \
libc6-dev flex libcurl4-gnutls-dev \
libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev\
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& git clone -b "main" https://github.com/citusdata/citus.git \
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
else \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-16-citus-12.1; \
fi \
&& pip3 install --break-system-packages setuptools \
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \
&& mkdir -p $PGHOME \
&& chown postgres $PGHOME \
@@ -26,6 +38,9 @@ RUN export DEBIAN_FRONTEND=noninteractive \
&& chmod 664 /etc/passwd \
# Clean up
&& apt-get remove -y git python3-pip python3-wheel \
postgresql-server-dev-16 gcc make autoconf \
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /root/.cache
+7 -53
View File
@@ -3,23 +3,14 @@
:var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables.
:var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables.
:var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work.
:var MIN_PSYCOPG3: minimum version of :mod:`psycopg` required by Patroni to work.
"""
import sys
from typing import Any, Callable, Iterator, Tuple
from typing import Iterator, Tuple
PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
MIN_PSYCOPG2 = (2, 5, 4)
def fatal(string: str, *args: Any) -> None:
"""Write a fatal message to stderr and exit with code ``1``.
:param string: message to be written before exiting.
"""
sys.exit('FATAL: ' + string.format(*args))
MIN_PSYCOPG3 = (3, 0, 0)
def parse_version(version: str) -> Tuple[int, ...]:
@@ -28,25 +19,25 @@ def parse_version(version: str) -> Tuple[int, ...]:
.. note::
Designed for easy comparison of software versions in Python.
:param version: human-readable software version, e.g. ``2.5.4``.
:param version: human-readable software version, e.g. ``2.5.4.dev1 (dt dec pq3 ext lo64)``.
:returns: tuple of *version* parts, each part as an integer.
:Example:
>>> parse_version('2.5.4')
>>> parse_version('2.5.4.dev1 (dt dec pq3 ext lo64)')
(2, 5, 4)
"""
def _parse_version(version: str) -> Iterator[int]:
"""Yield each part of a human-readable version string as an integer.
:param version: human-readable software version, e.g. ``2.5.4``.
:param version: human-readable software version, e.g. ``2.5.4.dev1``.
:yields: each part of *version* as an integer.
:Example:
>>> tuple(_parse_version('2.5.4'))
>>> tuple(_parse_version('2.5.4.dev1'))
(2, 5, 4)
"""
for e in version.split('.'):
@@ -55,40 +46,3 @@ def parse_version(version: str) -> Tuple[int, ...]:
except ValueError:
break
return tuple(_parse_version(version.split(' ')[0]))
def check_psycopg(_min_psycopg2: Tuple[int, ...] = MIN_PSYCOPG2,
_parse_version: Callable[[str], Tuple[int, ...]] = parse_version) -> None:
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
.. note::
We pass ``MIN_PSYCOPG2`` and :func:`parse_version` as arguments to simplify usage of :func:`check_psycopg` from
the ``setup.py``.
.. note::
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
If nothing meeting the requirements is found, then exit with a fatal message.
:param _min_psycopg2: minimum required version in case :mod:`psycopg2` is chosen.
:param _parse_version: function used to parse :mod:`psycopg2`/:mod:`psycopg` version into a comparable object.
"""
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
# try psycopg2
try:
from psycopg2 import __version__
if _parse_version(__version__) >= _min_psycopg2:
return
version_str = __version__.split(' ')[0]
except ImportError:
version_str = None
# try psycopg3
try:
from psycopg import __version__
except ImportError:
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
if version_str is not None:
error += ', but only psycopg2=={0} is available'.format(version_str)
fatal(error)
+194 -30
View File
@@ -1,3 +1,8 @@
"""Patroni main entry point.
Implement ``patroni`` main daemon and expose its entry point.
"""
import logging
import os
import signal
@@ -5,9 +10,11 @@ import sys
import time
from argparse import Namespace
from typing import Any, Dict, Optional, TYPE_CHECKING
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3, parse_version
from patroni.daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser
from patroni.tags import Tags
if TYPE_CHECKING: # pragma: no cover
from .config import Config
@@ -15,9 +22,33 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
class Patroni(AbstractPatroniDaemon):
class Patroni(AbstractPatroniDaemon, Tags):
"""Implement ``patroni`` command daemon.
:ivar version: Patroni version.
:ivar dcs: DCS object.
:ivar watchdog: watchdog handler, if configured to use watchdog.
:ivar postgresql: managed Postgres instance.
:ivar api: REST API server instance of this node.
:ivar request: wrapper for performing HTTP requests.
:ivar ha: HA handler.
:ivar next_run: time when to run the next HA loop cycle.
:ivar scheduled_restart: when a restart has been scheduled to occur, if any. In that case, should contain two keys:
* ``schedule``: timestamp when restart should occur;
* ``postmaster_start_time``: timestamp when Postgres was last started.
"""
def __init__(self, config: 'Config') -> None:
"""Create a :class:`Patroni` instance with the given *config*.
Get a connection to the DCS, configure watchdog (if required), set up Patroni interface with Postgres, configure
the HA loop and bring the REST API up.
.. note::
Expected to be instantiated and run through :func:`~patroni.daemon.abstract_main`.
:param config: Patroni configuration.
"""
from patroni.api import RestApiServer
from patroni.dcs import get_dcs
from patroni.ha import Ha
@@ -37,15 +68,26 @@ class Patroni(AbstractPatroniDaemon):
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.postgresql = Postgresql(self.config['postgresql'], self.dcs.mpp)
self.api = RestApiServer(self, self.config['restapi'])
self.ha = Ha(self)
self.tags = self.get_tags()
self._tags = self._get_tags()
self.next_run = time.time()
self.scheduled_restart: Dict[str, Any] = {}
def load_dynamic_configuration(self) -> None:
"""Load Patroni dynamic configuration.
Load dynamic configuration from the DCS, if `/config` key is available in the DCS, otherwise fall back to
``bootstrap.dcs`` section from the configuration file.
If the DCS connection fails returning the exception :class:`~patroni.exceptions.DCSError` an attempt will be
remade every 5 seconds.
.. note::
This method is called only once, at the time when Patroni is started.
"""
from patroni.exceptions import DCSError
while True:
try:
@@ -74,29 +116,40 @@ class Patroni(AbstractPatroniDaemon):
if not isinstance(member, Member):
return
try:
# Silence annoying WARNING: Retrying (...) messages when Patroni is quickly restarted.
# At this moment we don't have custom log levels configured and hence shouldn't lose anything useful.
self.logger.update_loggers({'urllib3.connectionpool': 'ERROR'})
_ = self.request(member, endpoint="/liveness", timeout=3)
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
sys.exit(1)
except Exception:
return
self.logger.update_loggers({})
def get_tags(self) -> Dict[str, Any]:
return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
def _get_tags(self) -> Dict[str, Any]:
"""Get tags configured for this node, if any.
@property
def nofailover(self) -> bool:
return bool(self.tags.get('nofailover', False))
@property
def nosync(self) -> bool:
return bool(self.tags.get('nosync', False))
:returns: a dictionary of tags set for this node.
"""
return self._filter_tags(self.config.get('tags', {}))
def reload_config(self, sighup: bool = False, local: Optional[bool] = False) -> None:
"""Apply new configuration values for ``patroni`` daemon.
Reload:
* Cached tags;
* Request wrapper configuration;
* REST API configuration;
* Watchdog configuration;
* Postgres configuration;
* DCS configuration.
:param sighup: if it is related to a SIGHUP signal.
:param local: if there has been changes to the local configuration file.
"""
try:
super(Patroni, self).reload_config(sighup, local)
if local:
self.tags = self.get_tags()
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'])
@@ -107,14 +160,16 @@ class Patroni(AbstractPatroniDaemon):
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 tags(self) -> Dict[str, Any]:
"""Tags configured for this node, if any."""
return self._tags
def schedule_next_run(self) -> None:
"""Schedule the next run of the ``patroni`` daemon main loop.
Next run is scheduled based on previous run plus value of ``loop_wait`` configuration from DCS. If that has
already been exceeded, run the next cycle immediately.
"""
self.next_run += self.dcs.loop_wait
current_time = time.time()
nap_time = self.next_run - current_time
@@ -128,11 +183,21 @@ class Patroni(AbstractPatroniDaemon):
self.next_run = time.time()
def run(self) -> None:
"""Run ``patroni`` daemon process main loop.
Start the REST API and keep running HA cycles every ``loop_wait`` seconds.
"""
self.api.start()
self.next_run = time.time()
super(Patroni, self).run()
def _run_cycle(self) -> None:
"""Run a cycle of the ``patroni`` daemon main loop.
Run an HA cycle and schedule the next cycle run. If any dynamic configuration change request is detected, apply
the change and cache the new dynamic configuration values in ``patroni.dynamic.json`` file under Postgres data
directory.
"""
logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
@@ -145,6 +210,10 @@ class Patroni(AbstractPatroniDaemon):
self.schedule_next_run()
def _shutdown(self) -> None:
"""Perform shutdown of ``patroni`` daemon process.
Shut down the REST API and the HA handler.
"""
try:
self.api.shutdown()
except Exception:
@@ -156,18 +225,49 @@ class Patroni(AbstractPatroniDaemon):
def patroni_main(configfile: str) -> None:
from multiprocessing import freeze_support
"""Configure and start ``patroni`` main daemon process.
freeze_support()
:param configfile: path to Patroni configuration file.
"""
abstract_main(Patroni, configfile)
def process_arguments() -> Namespace:
"""Process command-line arguments.
Create a basic command-line parser through :func:`~patroni.daemon.get_base_arg_parser`, extend its capabilities by
adding these flags and parse command-line arguments.:
* ``--validate-config`` -- used to validate the Patroni configuration file
* ``--generate-config`` -- used to generate Patroni configuration from a running PostgreSQL instance
* ``--generate-sample-config`` -- used to generate a sample Patroni configuration
.. note::
If running with ``--generate-config``, ``--generate-sample-config`` or ``--validate-flag`` will exit
after generating or validating configuration.
:returns: parsed arguments, if not running with ``--validate-config`` flag.
"""
from patroni.config_generator import generate_config
parser = get_base_arg_parser()
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
group = parser.add_mutually_exclusive_group()
group.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
group.add_argument('--generate-sample-config', action='store_true',
help='Generate a sample Patroni yaml configuration file')
group.add_argument('--generate-config', action='store_true',
help='Generate a Patroni yaml configuration file for a running instance')
parser.add_argument('--dsn', help='Optional DSN string of the instance to be used as a source \
for config generation. Superuser connection is required.')
args = parser.parse_args()
if args.validate_config:
if args.generate_sample_config:
generate_config(args.configfile, True, None)
sys.exit(0)
elif args.generate_config:
generate_config(args.configfile, False, args.dsn)
sys.exit(0)
elif args.validate_config:
from patroni.validator import schema
from patroni.config import Config, ConfigParseError
@@ -180,13 +280,66 @@ def process_arguments() -> Namespace:
return args
def main() -> None:
from patroni import check_psycopg
def check_psycopg() -> None:
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
args = process_arguments()
.. note::
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
If nothing meeting the requirements is found, then exit with a fatal message.
"""
min_psycopg2_str = '.'.join(map(str, MIN_PSYCOPG2))
min_psycopg3_str = '.'.join(map(str, MIN_PSYCOPG3))
available_versions: List[str] = []
# try psycopg2
try:
from psycopg2 import __version__
if parse_version(__version__) >= MIN_PSYCOPG2:
return
available_versions.append('psycopg2=={0}'.format(__version__.split(' ')[0]))
except ImportError:
logger.debug('psycopg2 module is not available')
# try psycopg3
try:
from psycopg import __version__
if parse_version(__version__) >= MIN_PSYCOPG3:
return
available_versions.append('psycopg=={0}'.format(__version__.split(' ')[0]))
except ImportError:
logger.debug('psycopg module is not available')
error = f'FATAL: Patroni requires psycopg2>={min_psycopg2_str}, psycopg2-binary, or psycopg>={min_psycopg3_str}'
if available_versions:
error += ', but only {0} {1} available'.format(
' and '.join(available_versions),
'is' if len(available_versions) == 1 else 'are')
sys.exit(error)
def main() -> None:
"""Main entrypoint of :mod:`patroni.__main__`.
Process command-line arguments, ensure :mod:`psycopg2` (or :mod:`psycopg`) attendee the pre-requisites and start
``patroni`` daemon process.
.. note::
If running through a Docker container, make the main process take care of init process duties and run
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
to ``patroni`` daemon process.
"""
from multiprocessing import freeze_support
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
check_psycopg()
args = process_arguments()
if os.getpid() != 1:
return patroni_main(args.configfile)
@@ -196,7 +349,13 @@ def main() -> None:
# Looks like we are in a docker, so we will act like init
def sigchld_handler(signo: int, stack_frame: Optional[FrameType]) -> None:
"""Handle ``SIGCHLD`` received by main process from ``patroni`` daemon when the daemon terminates.
:param signo: signal number.
:param stack_frame: current stack frame.
"""
try:
# log exit code of all children processes, and break loop when there is none left
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
@@ -206,7 +365,12 @@ def main() -> None:
except OSError:
pass
def passtochild(signo: int, stack_frame: Optional[FrameType]):
def passtochild(signo: int, stack_frame: Optional[FrameType]) -> None:
"""Forward a signal *signo* from main process to child process.
:param signo: signal number.
:param stack_frame: current stack frame.
"""
if pid:
os.kill(pid, signo)
+106 -74
View File
@@ -26,7 +26,7 @@ from urllib.parse import urlparse, parse_qs
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
from . import psycopg
from . import global_config, psycopg
from .__main__ import Patroni
from .dcs import Cluster
from .exceptions import PostgresConnectionException, PostgresException
@@ -37,7 +37,7 @@ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Ret
logger = logging.getLogger(__name__)
def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None]:
def check_access(func: Callable[..., None]) -> Callable[..., None]:
"""Check the source ip, authorization header, or client certificates.
.. note::
@@ -103,7 +103,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
if TYPE_CHECKING: # pragma: no cover
assert isinstance(server, RestApiServer)
super(RestApiHandler, self).__init__(request, client_address, server)
self.server: 'RestApiServer' = server
self.server: 'RestApiServer' = server # pyright: ignore [reportIncompatibleVariableOverride]
self.__start_time: float = 0.0
self.path_query: Dict[str, List[str]] = {}
@@ -180,6 +180,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
* ``pending_restart_reason``: dictionary where each key is the parameter that caused "pending restart" flag
to be set and the value is a dictionary with the old and the new value.
* ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the
scheduled restart;
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
@@ -196,9 +198,14 @@ class RestApiHandler(BaseHTTPRequestHandler):
response['tags'] = tags
if patroni.postgresql.sysid:
response['database_system_identifier'] = patroni.postgresql.sysid
if patroni.postgresql.pending_restart:
if patroni.postgresql.pending_restart_reason:
response['pending_restart'] = True
response['patroni'] = {'version': patroni.version, 'scope': patroni.postgresql.scope}
response['pending_restart_reason'] = dict(patroni.postgresql.pending_restart_reason)
response['patroni'] = {
'version': patroni.version,
'scope': patroni.postgresql.scope,
'name': patroni.postgresql.name
}
if patroni.scheduled_restart:
response['scheduled_restart'] = patroni.scheduled_restart.copy()
del response['scheduled_restart']['postmaster_start_time']
@@ -286,7 +293,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
patroni = self.server.patroni
cluster = patroni.dcs.cluster
global_config = patroni.config.get_global_config(cluster)
config = global_config.from_cluster(cluster)
leader_optime = cluster and cluster.last_lsn or 0
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
@@ -304,7 +311,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
elif patroni.ha.is_leader():
leader_status_code = 200
if global_config.is_standby_cluster:
if config.is_standby_cluster:
primary_status_code = replica_status_code = 503
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
else:
@@ -447,9 +454,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
Write an HTTP response with JSON content based on the output of :func:`~patroni.utils.cluster_as_json`, with
HTTP status ``200`` and the JSON representation of the cluster topology.
"""
cluster = self.server.patroni.dcs.get_cluster(True)
global_config = self.server.patroni.config.get_global_config(cluster)
self._write_json_response(200, cluster_as_json(cluster, global_config))
cluster = self.server.patroni.dcs.get_cluster()
response = cluster_as_json(cluster)
response['scope'] = self.server.patroni.postgresql.scope
self._write_json_response(200, response)
def do_GET_history(self) -> None:
"""Handle a ``GET`` request to ``/history`` path.
@@ -526,113 +535,113 @@ class RestApiHandler(BaseHTTPRequestHandler):
metrics: List[str] = []
scope_label = '{{scope="{0}"}}'.format(patroni.postgresql.scope)
labels = f'{{scope="{patroni.postgresql.scope}",name="{patroni.postgresql.name}"}}'
metrics.append("# HELP patroni_version Patroni semver without periods.")
metrics.append("# TYPE patroni_version gauge")
padded_semver = ''.join([x.zfill(2) for x in patroni.version.split('.')]) # 2.0.2 => 020002
metrics.append("patroni_version{0} {1}".format(scope_label, padded_semver))
metrics.append("patroni_version{0} {1}".format(labels, padded_semver))
metrics.append("# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.")
metrics.append("# TYPE patroni_postgres_running gauge")
metrics.append("patroni_postgres_running{0} {1}".format(scope_label, int(postgres['state'] == 'running')))
metrics.append("patroni_postgres_running{0} {1}".format(labels, int(postgres['state'] == 'running')))
metrics.append("# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.")
metrics.append("# TYPE patroni_postmaster_start_time gauge")
postmaster_start_time = postgres.get('postmaster_start_time')
postmaster_start_time = (postmaster_start_time - epoch).total_seconds() if postmaster_start_time else 0
metrics.append("patroni_postmaster_start_time{0} {1}".format(scope_label, postmaster_start_time))
metrics.append("patroni_postmaster_start_time{0} {1}".format(labels, postmaster_start_time))
metrics.append("# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.")
metrics.append("# TYPE patroni_master gauge")
metrics.append("patroni_master{0} {1}".format(scope_label, int(postgres['role'] in ('master', 'primary'))))
metrics.append("patroni_master{0} {1}".format(labels, int(postgres['role'] in ('master', 'primary'))))
metrics.append("# HELP patroni_primary Value is 1 if this node is the leader, 0 otherwise.")
metrics.append("# TYPE patroni_primary gauge")
metrics.append("patroni_primary{0} {1}".format(scope_label, int(postgres['role'] in ('master', 'primary'))))
metrics.append("patroni_primary{0} {1}".format(labels, int(postgres['role'] in ('master', 'primary'))))
metrics.append("# HELP patroni_xlog_location Current location of the Postgres"
" transaction log, 0 if this node is not the leader.")
metrics.append("# TYPE patroni_xlog_location counter")
metrics.append("patroni_xlog_location{0} {1}".format(scope_label, postgres.get('xlog', {}).get('location', 0)))
metrics.append("patroni_xlog_location{0} {1}".format(labels, postgres.get('xlog', {}).get('location', 0)))
metrics.append("# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.")
metrics.append("# TYPE patroni_standby_leader gauge")
metrics.append("patroni_standby_leader{0} {1}".format(scope_label, int(postgres['role'] == 'standby_leader')))
metrics.append("patroni_standby_leader{0} {1}".format(labels, int(postgres['role'] == 'standby_leader')))
metrics.append("# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.")
metrics.append("# TYPE patroni_replica gauge")
metrics.append("patroni_replica{0} {1}".format(scope_label, int(postgres['role'] == 'replica')))
metrics.append("patroni_replica{0} {1}".format(labels, int(postgres['role'] == 'replica')))
metrics.append("# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.")
metrics.append("# TYPE patroni_sync_standby gauge")
metrics.append("patroni_sync_standby{0} {1}".format(scope_label, int(postgres.get('sync_standby', False))))
metrics.append("patroni_sync_standby{0} {1}".format(labels, int(postgres.get('sync_standby', False))))
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
" Postgres transaction log, 0 if this node is not a replica.")
metrics.append("# TYPE patroni_xlog_received_location counter")
metrics.append("patroni_xlog_received_location{0} {1}"
.format(scope_label, postgres.get('xlog', {}).get('received_location', 0)))
.format(labels, postgres.get('xlog', {}).get('received_location', 0)))
metrics.append("# HELP patroni_xlog_replayed_location Current location of the replayed"
" Postgres transaction log, 0 if this node is not a replica.")
metrics.append("# TYPE patroni_xlog_replayed_location counter")
metrics.append("patroni_xlog_replayed_location{0} {1}"
.format(scope_label, postgres.get('xlog', {}).get('replayed_location', 0)))
.format(labels, postgres.get('xlog', {}).get('replayed_location', 0)))
metrics.append("# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed"
" Postgres transaction log, 0 if null.")
metrics.append("# TYPE patroni_xlog_replayed_timestamp gauge")
replayed_timestamp = postgres.get('xlog', {}).get('replayed_timestamp')
replayed_timestamp = (replayed_timestamp - epoch).total_seconds() if replayed_timestamp else 0
metrics.append("patroni_xlog_replayed_timestamp{0} {1}".format(scope_label, replayed_timestamp))
metrics.append("patroni_xlog_replayed_timestamp{0} {1}".format(labels, replayed_timestamp))
metrics.append("# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.")
metrics.append("# TYPE patroni_xlog_paused gauge")
metrics.append("patroni_xlog_paused{0} {1}"
.format(scope_label, int(postgres.get('xlog', {}).get('paused', False) is True)))
.format(labels, int(postgres.get('xlog', {}).get('paused', False) is True)))
if postgres.get('server_version', 0) >= 90600:
metrics.append("# HELP patroni_postgres_streaming Value is 1 if Postgres is streaming, 0 otherwise.")
metrics.append("# TYPE patroni_postgres_streaming gauge")
metrics.append("patroni_postgres_streaming{0} {1}"
.format(scope_label, int(postgres.get('replication_state') == 'streaming')))
.format(labels, int(postgres.get('replication_state') == 'streaming')))
metrics.append("# HELP patroni_postgres_in_archive_recovery Value is 1"
" if Postgres is replicating from archive, 0 otherwise.")
metrics.append("# TYPE patroni_postgres_in_archive_recovery gauge")
metrics.append("patroni_postgres_in_archive_recovery{0} {1}"
.format(scope_label, int(postgres.get('replication_state') == 'in archive recovery')))
.format(labels, int(postgres.get('replication_state') == 'in archive recovery')))
metrics.append("# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.")
metrics.append("# TYPE patroni_postgres_server_version gauge")
metrics.append("patroni_postgres_server_version {0} {1}".format(scope_label, postgres.get('server_version', 0)))
metrics.append("patroni_postgres_server_version {0} {1}".format(labels, postgres.get('server_version', 0)))
metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.")
metrics.append("# TYPE patroni_cluster_unlocked gauge")
metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres.get('cluster_unlocked', 0))))
metrics.append("patroni_cluster_unlocked{0} {1}".format(labels, int(postgres.get('cluster_unlocked', 0))))
metrics.append("# HELP patroni_failsafe_mode_is_active Value is 1 if failsafe mode is active, 0 if inactive.")
metrics.append("# TYPE patroni_failsafe_mode_is_active gauge")
metrics.append("patroni_failsafe_mode_is_active{0} {1}"
.format(scope_label, int(postgres.get('failsafe_mode_is_active', 0))))
.format(labels, int(postgres.get('failsafe_mode_is_active', 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("patroni_postgres_timeline{0} {1}".format(labels, 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("patroni_dcs_last_seen{0} {1}".format(labels, 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)))
.format(labels, int(bool(patroni.postgresql.pending_restart_reason))))
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(postgres.get('pause', 0))))
metrics.append("patroni_is_paused{0} {1}".format(labels, int(postgres.get('pause', 0))))
self.write_response(200, '\n'.join(metrics) + '\n', content_type='text/plain')
@@ -683,7 +692,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
"""
request = self._read_json_content()
if request:
cluster = self.server.patroni.dcs.get_cluster(True)
cluster = self.server.patroni.dcs.get_cluster()
if not (cluster.config and cluster.config.modify_version):
return self.send_error(503)
data = cluster.config.data.copy()
@@ -857,7 +866,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
if request:
logger.debug("received restart request: {0}".format(request))
if self.server.patroni.config.get_global_config(cluster).is_paused and 'schedule' in request:
if global_config.from_cluster(cluster).is_paused and 'schedule' in request:
self.write_response(status_code, "Can't schedule restart in the paused state")
return
@@ -1026,7 +1035,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
:returns: a string with the error message or ``None`` if good nodes are found.
"""
is_synchronous_mode = self.server.patroni.config.get_global_config(cluster).is_synchronous_mode
is_synchronous_mode = global_config.from_cluster(cluster).is_synchronous_mode
if leader and (not cluster.leader or cluster.leader.name != leader):
return 'leader name does not match'
if candidate:
@@ -1067,7 +1076,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
* ``412``: if operation is not possible;
* ``503``: if unable to register the operation to the DCS;
* HTTP status returned by :func:`parse_schedule`, if any error was observed while parsing the schedule;
* HTTP status returned by :func:`poll_failover_result` if the operation has been processed immediately.
* HTTP status returned by :func:`poll_failover_result` if the operation has been processed immediately;
* ``400``: if none of the above applies.
.. note::
If unable to parse the request body, then the request is silently discarded.
@@ -1083,7 +1093,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
candidate = request.get('candidate') or request.get('member')
scheduled_at = request.get('scheduled_at')
cluster = self.server.patroni.dcs.get_cluster()
global_config = self.server.patroni.config.get_global_config(cluster)
config = global_config.from_cluster(cluster)
logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
action, leader, candidate, scheduled_at)
@@ -1094,15 +1104,22 @@ class RestApiHandler(BaseHTTPRequestHandler):
data = 'Switchover could be performed only from a specific leader'
if not data and scheduled_at:
if not leader:
data = 'Scheduled {0} is possible only from a specific leader'.format(action)
if not data and global_config.is_paused:
data = "Can't schedule {0} in the paused state".format(action)
if not data:
if action == 'failover':
data = "Failover can't be scheduled"
elif config.is_paused:
data = "Can't schedule switchover in the paused state"
else:
(status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action)
if not data and global_config.is_paused and not candidate:
data = action.title() + ' is possible only to a specific candidate in a paused state'
if not data and config.is_paused and not candidate:
data = 'Switchover is possible only to a specific candidate in a paused state'
if action == 'failover' and leader:
logger.warning('received failover request with leader specifed - performing switchover instead')
action = 'switchover'
if not data and leader == candidate:
data = 'Switchover target and source are the same'
if not data and not scheduled_at:
data = self.is_failover_possible(cluster, leader, candidate, action)
@@ -1119,7 +1136,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name,
candidate, action)
else:
data = 'failed to write {0} key into DCS'.format(action)
data = 'failed to write failover key into DCS'
status_code = 503
# pyright thinks ``status_code`` can be ``None`` because ``parse_schedule`` call may return ``None``. However,
# if that's the case, ``status_code`` will be overwritten somewhere between ``parse_schedule`` and
@@ -1139,8 +1156,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
def do_POST_citus(self) -> None:
"""Handle a ``POST`` request to ``/citus`` path.
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
HTTP status code ``200``.
.. note::
We keep this entrypoint for backward compatibility and simply dispatch the request to :meth:`do_POST_mpp`.
"""
self.do_POST_mpp()
def do_POST_mpp(self) -> None:
"""Handle a ``POST`` request to ``/mpp`` path.
Call :func:`~patroni.postgresql.mpp.AbstractMPPHandler.handle_event` to handle the request,
then write a response with HTTP status code ``200``.
.. note::
If unable to parse the request body, then the request is silently discarded.
@@ -1150,9 +1175,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
return
patroni = self.server.patroni
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
cluster = patroni.dcs.get_cluster(True)
patroni.postgresql.citus_handler.handle_event(cluster, request)
if patroni.postgresql.mpp_handler.is_coordinator() and patroni.ha.is_leader():
cluster = patroni.dcs.get_cluster()
patroni.postgresql.mpp_handler.handle_event(cluster, request)
self.write_response(200, 'OK')
def parse_request(self) -> bool:
@@ -1183,20 +1208,18 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.command = mname
return ret
def query(self, sql: str, *params: Any, **kwargs: Any) -> List[Tuple[Any, ...]]:
"""Execute *sql* query with *params*.
def query(self, sql: str, *params: Any, retry: bool = False) -> List[Tuple[Any, ...]]:
"""Execute *sql* query with *params* and optionally return results.
:param sql: the SQL statement to be run.
:param params: positional arguments to call :func:`RestApiServer.query` with.
:param kwargs: can contain the key ``retry``. If the key is present its value should be a :class:`bool` which
indicates whether the query should be retried upon failure or given up immediately.
:param retry: whether the query should be retried upon failure or given up immediately.
:returns: a list of rows that were fetched from the database.
"""
if not kwargs.get('retry', False):
if not retry:
return self.server.query(sql, *params)
retry = Retry(delay=1, retry_exceptions=PostgresConnectionException)
return retry(self.server.query, sql, *params)
return Retry(delay=1, retry_exceptions=PostgresConnectionException)(self.server.query, sql, *params)
def get_postgresql_status(self, retry: bool = False) -> Dict[str, Any]:
"""Builds an object representing a status of "postgres".
@@ -1247,7 +1270,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
"""
postgresql = self.server.patroni.postgresql
cluster = self.server.patroni.dcs.cluster
global_config = self.server.patroni.config.get_global_config(cluster)
config = global_config.from_cluster(cluster)
try:
if postgresql.state not in ('running', 'restarting', 'starting'):
@@ -1262,8 +1285,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
" application_name, client_addr, w.state, sync_state, sync_priority"
" FROM pg_catalog.pg_stat_get_wal_senders() w, pg_catalog.pg_stat_get_activity(pid)) AS ri")
row = self.query(stmt.format(postgresql.wal_name, postgresql.lsn_name), retry=retry)[0]
row = self.query(stmt.format(postgresql.wal_name, postgresql.lsn_name,
postgresql.wal_flush), retry=retry)[0]
result = {
'state': postgresql.state,
'postmaster_start_time': row[0],
@@ -1278,10 +1301,10 @@ class RestApiHandler(BaseHTTPRequestHandler):
})
}
if result['role'] == 'replica' and global_config.is_standby_cluster:
if result['role'] == 'replica' and config.is_standby_cluster:
result['role'] = postgresql.role
if result['role'] == 'replica' and global_config.is_synchronous_mode\
if result['role'] == 'replica' and config.is_synchronous_mode\
and cluster and cluster.sync.matches(postgresql.name):
result['sync_standby'] = True
@@ -1306,7 +1329,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
state = 'unknown'
result: Dict[str, Any] = {'state': state, 'role': postgresql.role}
if global_config.is_paused:
if config.is_paused:
result['pause'] = True
if not cluster or cluster.is_unlocked():
result['cluster_unlocked'] = True
@@ -1368,7 +1391,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
self.daemon = True
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
"""Execute *sql* query with *params*.
"""Execute *sql* query with *params* and optionally return results.
.. note::
Prefer to use own connection to postgres and fallback to ``heartbeat`` when own isn't available.
:param sql: the SQL statement to be run.
:param params: positional arguments to be used as parameters for *sql*.
@@ -1379,15 +1405,21 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
:class:`psycopg.Error`: if had issues while executing *sql*.
:class:`~patroni.exceptions.PostgresConnectionException`: if had issues while connecting to the database.
"""
cursor = None
# We first try to get a heartbeat connection because it is always required for the main thread.
try:
with self.patroni.postgresql.connection().cursor() as cursor:
cursor.execute(sql.encode('utf-8'), params)
return [r for r in cursor]
except psycopg.Error as e:
if cursor and cursor.connection.closed == 0:
raise e
raise PostgresConnectionException('connection problems')
heartbeat_connection = self.patroni.postgresql.connection_pool.get('heartbeat')
heartbeat_connection.get() # try to open psycopg connection to postgres
except psycopg.Error as exc:
raise PostgresConnectionException('connection problems') from exc
try:
connection = self.patroni.postgresql.connection_pool.get('restapi')
connection.get() # try to open psycopg connection to postgres
except psycopg.Error:
logger.debug('restapi connection to postgres is not available')
connection = heartbeat_connection
return connection.query(sql, *params)
@staticmethod
def _set_fd_cloexec(fd: socket.socket) -> None:
@@ -1553,7 +1585,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
:param listen: IP and port to bind REST API to. It should be a string in the format ``host:port``, where
``host`` can be a hostname or IP address. It is the value of ``restapi.listen`` setting.
:param ssl_options: dictionary that may contain the following keys, depending on what has been configured in
``restapi` section:
``restapi`` section:
* ``certfile``: path to PEM certificate. If given, will start in HTTPS mode;
* ``keyfile``: path to key of ``certfile``;
+56 -4
View File
@@ -1,9 +1,10 @@
"""Patroni custom object types somewhat like :mod:`collections` module.
Provides a case insensitive :class:`dict` and :class:`set` object types.
Provides a case insensitive :class:`dict` and :class:`set` object types, and `EMPTY_DICT` frozen dictionary object.
"""
from collections import OrderedDict
from typing import Any, Collection, Dict, Iterator, MutableMapping, MutableSet, Optional
from copy import deepcopy
from typing import Any, Collection, Dict, Iterator, KeysView, Mapping, MutableMapping, MutableSet, Optional
class CaseInsensitiveSet(MutableSet[str]):
@@ -48,7 +49,7 @@ class CaseInsensitiveSet(MutableSet[str]):
"""
return str(set(self._values.values()))
def __contains__(self, value: str) -> bool:
def __contains__(self, value: object) -> bool:
"""Check if set contains *value*.
The check is performed case-insensitively.
@@ -57,7 +58,7 @@ class CaseInsensitiveSet(MutableSet[str]):
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
"""
return value.lower() in self._values
return isinstance(value, str) and value.lower() in self._values
def __iter__(self) -> Iterator[str]:
"""Iterate over the values in this set.
@@ -187,6 +188,13 @@ class CaseInsensitiveDict(MutableMapping[str, Any]):
"""
return CaseInsensitiveDict({v[0]: v[1] for v in self._values.values()})
def keys(self) -> KeysView[str]:
"""Return a new view of the dict's keys.
:returns: a set-like object providing a view on the dict's keys
"""
return self._values.keys()
def __repr__(self) -> str:
"""Get a string representation of the dict.
@@ -200,3 +208,47 @@ class CaseInsensitiveDict(MutableMapping[str, Any]):
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
class _FrozenDict(Mapping[str, Any]):
"""Frozen dictionary object."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Create a new instance of :class:`_FrozenDict` with given data."""
self.__values: Dict[str, Any] = dict(*args, **kwargs)
def __iter__(self) -> Iterator[str]:
"""Iterate over keys of this dict.
:yields: each key present in the dict. Yields each key with its last case that has been stored.
"""
return iter(self.__values)
def __len__(self) -> int:
"""Get the length of this dict.
:returns: number of keys in the dict.
:Example:
>>> len(_FrozenDict())
0
"""
return len(self.__values)
def __getitem__(self, key: str) -> Any:
"""Get the value corresponding to *key*.
:returns: value corresponding to *key*.
"""
return self.__values[key]
def copy(self) -> Dict[str, Any]:
"""Create a copy of this dict.
:return: a new dict object with the same keys and values of this dict.
"""
return deepcopy(self.__values)
EMPTY_DICT = _FrozenDict()
+388 -169
View File
@@ -1,3 +1,5 @@
"""Facilities related to Patroni configuration."""
import re
import json
import logging
import os
@@ -10,8 +12,8 @@ from copy import deepcopy
from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
from . import PATRONI_ENV_PREFIX
from .collections import CaseInsensitiveDict
from .dcs import ClusterConfig, Cluster
from .collections import CaseInsensitiveDict, EMPTY_DICT
from .dcs import ClusterConfig
from .exceptions import ConfigParseError
from .file_perm import pg_perm
from .postgresql.config import ConfigHandler
@@ -36,148 +38,47 @@ _AUTH_ALLOWED_PARAMETERS = (
def default_validator(conf: Dict[str, Any]) -> List[str]:
"""Ensure *conf* is not empty.
Designed to be used as default validator for :class:`Config` objects, if no specific validator is provided.
:param conf: configuration to be validated.
:returns: an empty list -- :class:`Config` expects the validator to return a list of 0 or more issues found while
validating the configuration.
:raises:
:class:`ConfigParseError`: if *conf* is empty.
"""
if not conf:
raise ConfigParseError("Config is empty.")
return []
class GlobalConfig(object):
"""A class that wrapps global configuration and provides convinient methods to access/check values.
It is instantiated by calling :func:`Config.global_config` method which picks either a
configuration from provided :class:`Cluster` object (the most up-to-date) or from the
local cache if :class::`ClusterConfig` is not initialized or doesn't have a valid config.
"""
def __init__(self, config: Dict[str, Any]) -> None:
"""Initialize :class:`GlobalConfig` object.
:param config: current configuration either from
:class:`ClusterConfig` or from :class:`Config.dynamic_configuration`
"""
self.__config = config
def get(self, name: str) -> Any:
"""Gets global configuration value by name.
:param name: parameter name
:returns: configuration value or `None` if it is missing
"""
return self.__config.get(name)
def check_mode(self, mode: str) -> bool:
"""Checks whether the certain parameter is enabled.
:param mode: parameter name could be: synchronous_mode, failsafe_mode, pause, check_timeline, and so on
:returns: `True` if *mode* is enabled in the global configuration.
"""
return bool(parse_bool(self.__config.get(mode)))
@property
def is_paused(self) -> bool:
""":returns: `True` if cluster is in maintenance mode."""
return self.check_mode('pause')
@property
def is_synchronous_mode(self) -> bool:
""":returns: `True` if synchronous replication is requested."""
return self.check_mode('synchronous_mode')
@property
def is_synchronous_mode_strict(self) -> bool:
""":returns: `True` if at least one synchronous node is required."""
return self.check_mode('synchronous_mode_strict')
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
""":returns: "standby_cluster" configuration."""
return deepcopy(self.get('standby_cluster'))
@property
def is_standby_cluster(self) -> bool:
""":returns: `True` if global configuration has a valid "standby_cluster" section."""
config = self.get_standby_cluster_config()
return isinstance(config, dict) and\
bool(config.get('host') or config.get('port') or config.get('restore_command'))
def get_int(self, name: str, default: int = 0) -> int:
"""Gets current value from the global configuration and trying to return it as int.
:param name: name of the parameter
:param default: default value if *name* is not in the configuration or invalid
:returns: currently configured value from the global configuration or *default* if it is not set or invalid.
"""
ret = parse_int(self.get(name))
return default if ret is None else ret
@property
def min_synchronous_nodes(self) -> int:
""":returns: the minimal number of synchronous nodes based on whether strict mode is requested or not."""
return 1 if self.is_synchronous_mode_strict else 0
@property
def synchronous_node_count(self) -> int:
""":returns: currently configured value from the global configuration or 1 if it is not set or invalid."""
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
@property
def maximum_lag_on_failover(self) -> int:
""":returns: currently configured value from the global configuration or 1048576 if it is not set or invalid."""
return self.get_int('maximum_lag_on_failover', 1048576)
@property
def maximum_lag_on_syncnode(self) -> int:
""":returns: currently configured value from the global configuration or -1 if it is not set or invalid."""
return self.get_int('maximum_lag_on_syncnode', -1)
@property
def primary_start_timeout(self) -> int:
""":returns: currently configured value from the global configuration or 300 if it is not set or invalid."""
default = 300
return self.get_int('primary_start_timeout', default)\
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
@property
def primary_stop_timeout(self) -> int:
""":returns: currently configured value from the global configuration or 300 if it is not set or invalid."""
default = 0
return self.get_int('primary_stop_timeout', default)\
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
def get_global_config(cluster: Union[Cluster, None], default: Optional[Dict[str, Any]] = None) -> GlobalConfig:
"""Instantiates :class:`GlobalConfig` based on the input.
:param cluster: the currently known cluster state from DCS
:param default: default configuration, which will be used if there is no valid *cluster.config*
:returns: :class:`GlobalConfig` object
"""
# Try to protect from the case when DCS was wiped out
if cluster and cluster.config and cluster.config.modify_version:
config = cluster.config.data
else:
config = default or {}
return GlobalConfig(deepcopy(config))
class Config(object):
"""
"""Handle Patroni configuration.
This class is responsible for:
1) Building and giving access to `effective_configuration` from:
* `Config.__DEFAULT_CONFIG` -- some sane default values
* `dynamic_configuration` -- configuration stored in DCS
* `local_configuration` -- configuration from `config.yml` or environment
1) Building and giving access to ``effective_configuration`` from:
2) Saving and loading `dynamic_configuration` into 'patroni.dynamic.json' file
* ``Config.__DEFAULT_CONFIG`` -- some sane default values;
* ``dynamic_configuration`` -- configuration stored in DCS;
* ``local_configuration`` -- configuration from `config.yml` or environment.
2) Saving and loading ``dynamic_configuration`` into 'patroni.dynamic.json' file
located in local_configuration['postgresql']['data_dir'] directory.
This is necessary to be able to restore `dynamic_configuration`
if DCS was accidentally wiped
This is necessary to be able to restore ``dynamic_configuration``
if DCS was accidentally wiped.
3) Loading of configuration file in the old format and converting it into new format
3) Loading of configuration file in the old format and converting it into new format.
4) Mimicking some of the `dict` interfaces to make it possible
to work with it as with the old `config` object.
4) Mimicking some ``dict`` interfaces to make it possible
to work with it as with the old ``config`` object.
:cvar PATRONI_CONFIG_VARIABLE: name of the environment variable that can be used to load Patroni configuration from.
:cvar __CACHE_FILENAME: name of the file used to cache dynamic configuration under Postgres data directory.
:cvar __DEFAULT_CONFIG: default configuration values for some Patroni settings.
"""
PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION'
@@ -195,21 +96,38 @@ class Config(object):
'recovery_min_apply_delay': ''
},
'postgresql': {
'bin_dir': '',
'use_slots': True,
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()
if p not in ('wal_keep_segments', 'wal_keep_size')})
if v[0] is not None and p not in ('wal_keep_segments', 'wal_keep_size')})
}
}
def __init__(self, configfile: str,
validator: Optional[Callable[[Dict[str, Any]], List[str]]] = default_validator) -> None:
"""Create a new instance of :class:`Config` and validate the loaded configuration using *validator*.
.. note::
Patroni will read configuration from these locations in this order:
* file or directory path passed as command-line argument (*configfile*), if it exists and the file or
files found in the directory can be parsed (see :meth:`~Config._load_config_path`), otherwise
* YAML file passed via the environment variable (see :attr:`PATRONI_CONFIG_VARIABLE`), if the referenced
file exists and can be parsed, otherwise
* from configuration values defined as environment variables, see
:meth:`~Config._build_environment_configuration`.
:param configfile: path to Patroni configuration file.
:param validator: function used to validate Patroni configuration. It should receive a dictionary which
represents Patroni configuration, and return a list of zero or more error messages based on validation.
:raises:
:class:`ConfigParseError`: if any issue is reported by *validator*.
"""
self._modify_version = -1
self._dynamic_configuration = {}
self.__environment_configuration = self._build_environment_configuration()
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment
self._config_file = configfile if configfile and os.path.exists(configfile) else None
if self._config_file:
self._local_configuration = self._load_config_file()
@@ -225,22 +143,49 @@ class Config(object):
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
self._load_cache()
if validator: # patronictl uses validator=None
self._load_cache() # we don't want to load anything from local cache for ctl
self._validate_failover_tags() # irrelevant for ctl
self._cache_needs_saving = False
@property
def config_file(self) -> Union[str, None]:
def config_file(self) -> Optional[str]:
"""Path to Patroni configuration file, if any, else ``None``."""
return self._config_file
@property
def dynamic_configuration(self) -> Dict[str, Any]:
"""Deep copy of cached Patroni dynamic configuration."""
return deepcopy(self._dynamic_configuration)
def _load_config_path(self, path: str) -> Dict[str, Any]:
@property
def local_configuration(self) -> Dict[str, Any]:
"""Deep copy of cached Patroni local configuration.
:returns: copy of :attr:`~Config._local_configuration`
"""
If path is a file, loads the yml file pointed to by path.
If path is a directory, loads all yml files in that directory in alphabetical order
return deepcopy(dict(self._local_configuration))
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Deep copy default configuration.
:returns: copy of :attr:`~Config.__DEFAULT_CONFIG`
"""
return deepcopy(cls.__DEFAULT_CONFIG)
def _load_config_path(self, path: str) -> Dict[str, Any]:
"""Load Patroni configuration file(s) from *path*.
If *path* is a file, load the yml file pointed to by *path*.
If *path* is a directory, load all yml files in that directory in alphabetical order.
:param path: path to either an YAML configuration file, or to a folder containing YAML configuration files.
:returns: configuration after reading the configuration file(s) from *path*.
:raises:
:class:`ConfigParseError`: if *path* is invalid.
"""
if os.path.isfile(path):
files = [path]
@@ -259,14 +204,18 @@ class Config(object):
return overall_config
def _load_config_file(self) -> Dict[str, Any]:
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
"""Load configuration file(s) from filesystem and apply values which were set via environment variables.
:returns: final configuration after merging configuration file(s) and environment variables.
"""
if TYPE_CHECKING: # pragma: no cover
assert self._config_file is not None
config = self._load_config_path(self._config_file)
assert self.config_file is not None
config = self._load_config_path(self.config_file)
patch_config(config, self.__environment_configuration)
return config
def _load_cache(self) -> None:
"""Load dynamic configuration from ``patroni.dynamic.json``."""
if os.path.isfile(self._cache_file):
try:
with open(self._cache_file) as f:
@@ -275,6 +224,12 @@ class Config(object):
logger.exception('Exception when loading file: %s', self._cache_file)
def save_cache(self) -> None:
"""Save dynamic configuration to ``patroni.dynamic.json`` under Postgres data directory.
.. note::
``patroni.dynamic.jsonXXXXXX`` is created as a temporary file and than renamed to ``patroni.dynamic.json``,
where ``XXXXXX`` is a random suffix.
"""
if self._cache_needs_saving:
tmpfile = fd = None
try:
@@ -299,16 +254,84 @@ class Config(object):
except Exception:
logger.error('Can not remove temporary file %s', tmpfile)
def __get_and_maybe_adjust_int_value(self, config: Dict[str, Any], param: str, min_value: int) -> int:
"""Get, validate and maybe adjust a *param* integer value from the *config* :class:`dict`.
.. note:
If the value is smaller than provided *min_value* we update the *config*.
This method may raise an exception if value isn't :class:`int` or cannot be casted to :class:`int`.
:param config: :class:`dict` object with new global configuration.
:param param: name of the configuration parameter we want to read/validate/adjust.
:param min_value: the minimum possible value that a given *param* could have.
:returns: an integer value which corresponds to a provided *param*.
"""
value = int(config.get(param, self.__DEFAULT_CONFIG[param]))
if value < min_value:
logger.warning("%s=%d can't be smaller than %d, adjusting...", param, value, min_value)
value = config[param] = min_value
return value
def _validate_and_adjust_timeouts(self, config: Dict[str, Any]) -> None:
"""Validate and adjust ``loop_wait``, ``retry_timeout``, and ``ttl`` values if necessary.
Minimum values:
* ``loop_wait``: 1 second;
* ``retry_timeout``: 3 seconds.
* ``ttl``: 20 seconds;
Maximum values:
In case if values don't fulfill the following rule, ``retry_timeout`` and ``loop_wait``
are reduced so that the rule is fulfilled:
.. code-block:: python
loop_wait + 2 * retry_timeout <= ttl
.. note:
We prefer to reduce ``loop_wait`` and will reduce ``retry_timeout`` only if ``loop_wait``
is already set to a minimal possible value.
:param config: :class:`dict` object with new global configuration.
"""
min_loop_wait = 1
loop_wait = self. __get_and_maybe_adjust_int_value(config, 'loop_wait', min_loop_wait)
retry_timeout = self. __get_and_maybe_adjust_int_value(config, 'retry_timeout', 3)
ttl = self. __get_and_maybe_adjust_int_value(config, 'ttl', 20)
if min_loop_wait + 2 * retry_timeout > ttl:
config['loop_wait'] = min_loop_wait
config['retry_timeout'] = (ttl - min_loop_wait) // 2
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. '
'Adjusting loop_wait from %d to %d and retry_timeout from %d to %d',
ttl, loop_wait, min_loop_wait, retry_timeout, config['retry_timeout'])
elif loop_wait + 2 * retry_timeout > ttl:
config['loop_wait'] = ttl - 2 * retry_timeout
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d and retry_timeout=%d.'
' Adjusting loop_wait from %d to %d', ttl, retry_timeout, loop_wait, config['loop_wait'])
# configuration could be either ClusterConfig or dict
def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool:
"""Set dynamic configuration values with given *configuration*.
:param configuration: new dynamic configuration values. Supports :class:`dict` for backward compatibility.
:returns: ``True`` if changes have been detected between current dynamic configuration and the new dynamic
*configuration*, ``False`` otherwise.
"""
if isinstance(configuration, ClusterConfig):
if self._modify_version == configuration.modify_version:
return False # If the version didn't changed there is nothing to do
return False # If the version didn't change there is nothing to do
self._modify_version = configuration.modify_version
configuration = configuration.data
if not deep_compare(self._dynamic_configuration, configuration):
try:
self._validate_and_adjust_timeouts(configuration)
self.__effective_configuration = self._build_effective_configuration(configuration,
self._local_configuration)
self._dynamic_configuration = configuration
@@ -319,6 +342,14 @@ class Config(object):
return False
def reload_local_configuration(self) -> Optional[bool]:
"""Reload configuration values from the configuration file(s).
.. note::
Designed to be used when user applies changes to configuration file(s), so Patroni can use the new values
with a reload instead of a restart.
:returns: ``True`` if changes have been detected between current local configuration
"""
if self.config_file:
try:
configuration = self._load_config_file()
@@ -326,6 +357,7 @@ class Config(object):
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
self._local_configuration = configuration
self.__effective_configuration = new_configuration
self._validate_failover_tags()
return True
else:
logger.info('No local configuration items changed.')
@@ -334,6 +366,38 @@ class Config(object):
@staticmethod
def _process_postgresql_parameters(parameters: Dict[str, Any], is_local: bool = False) -> Dict[str, Any]:
"""Process Postgres *parameters*.
.. note::
If *is_local* configuration discard any setting from *parameters* that is listed under
:attr:`~patroni.postgresql.config.ConfigHandler.CMDLINE_OPTIONS` as those are supposed to be set only
through dynamic configuration.
When setting parameters from :attr:`~patroni.postgresql.config.ConfigHandler.CMDLINE_OPTIONS` through
dynamic configuration their value will be validated as per the validator defined in that very same
attribute entry. If the given value cannot be validated, a warning will be logged and the default value of
the GUC will be used instead.
Some parameters from :attr:`~patroni.postgresql.config.ConfigHandler.CMDLINE_OPTIONS` cannot be set even if
not *is_local* configuration:
* ``listen_addresses``: inferred from ``postgresql.listen`` local configuration or from
``PATRONI_POSTGRESQL_LISTEN`` environment variable;
* ``port``: inferred from ``postgresql.listen`` local configuration or from
``PATRONI_POSTGRESQL_LISTEN`` environment variable;
* ``cluster_name``: set through ``scope`` local configuration or through ``PATRONI_SCOPE`` environment
variable;
* ``hot_standby``: always enabled;
* ``wal_log_hints``: always enabled.
:param parameters: Postgres parameters to be processed. Should be the parsed YAML value of
``postgresql.parameters`` configuration, either from local or from dynamic configuration.
:param is_local: should be ``True`` if *parameters* refers to local configuration, or ``False`` if *parameters*
refers to dynamic configuration.
:returns: new value for ``postgresql.parameters`` after processing and validating *parameters*.
"""
pg_params: Dict[str, Any] = {}
for name, value in (parameters or {}).items():
@@ -342,7 +406,8 @@ class Config(object):
elif not is_local:
validator = ConfigHandler.CMDLINE_OPTIONS[name][1]
if validator(value):
pg_params[name] = int(value) if isinstance(validator, IntValidator) else value
int_val = parse_int(value) if isinstance(validator, IntValidator) else None
pg_params[name] = int_val if isinstance(int_val, int) else value
else:
logger.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
@@ -350,18 +415,44 @@ class Config(object):
return pg_params
def _safe_copy_dynamic_configuration(self, dynamic_configuration: Dict[str, Any]) -> Dict[str, Any]:
config = deepcopy(self.__DEFAULT_CONFIG)
"""Create a copy of *dynamic_configuration*.
Merge *dynamic_configuration* with :attr:`__DEFAULT_CONFIG` (*dynamic_configuration* takes precedence), and
process ``postgresql.parameters`` from *dynamic_configuration* through :func:`_process_postgresql_parameters`,
if present.
.. note::
The following settings are not allowed in ``postgresql`` section as they are intended to be local
configuration, and are removed if present:
* ``connect_address``;
* ``proxy_address``;
* ``listen``;
* ``config_dir``;
* ``data_dir``;
* ``pgpass``;
* ``authentication``;
Besides that any setting present in *dynamic_configuration* but absent from :attr:`__DEFAULT_CONFIG` is
discarded.
:param dynamic_configuration: Patroni dynamic configuration.
:returns: copy of *dynamic_configuration*, merged with default dynamic configuration and with some sanity checks
performed over it.
"""
config = self.get_default_config()
for name, value in dynamic_configuration.items():
if name == 'postgresql':
for name, value in (value or {}).items():
for name, value in (value or EMPTY_DICT).items():
if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value))
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():
for name, value in (value or EMPTY_DICT).items():
if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
@@ -370,9 +461,25 @@ class Config(object):
@staticmethod
def _build_environment_configuration() -> Dict[str, Any]:
"""Get local configuration settings that were specified through environment variables.
:returns: dictionary containing the found environment variables and their values, respecting the expected
structure of Patroni configuration.
"""
ret: Dict[str, Any] = defaultdict(dict)
def _popenv(name: str) -> Union[str, None]:
def _popenv(name: str) -> Optional[str]:
"""Get value of environment variable *name*.
.. note::
*name* is prefixed with :data:`~patroni.PATRONI_ENV_PREFIX` when searching in the environment.
Also, the corresponding environment variable is removed from the environment upon reading its value.
:param name: name of the environment variable.
:returns: value of *name*, if present in the environment, otherwise ``None``.
"""
return os.environ.pop(PATRONI_ENV_PREFIX + name.upper(), None)
for param in ('name', 'namespace', 'scope'):
@@ -381,6 +488,23 @@ class Config(object):
ret[param] = value
def _fix_log_env(name: str, oldname: str) -> None:
"""Normalize a log related environment variable.
.. note::
Patroni used to support different names for log related environment variables in the past. As the
environment variables were renamed, this function takes care of mapping and normalizing the environment.
*name* is prefixed with :data:`~patroni.PATRONI_ENV_PREFIX` and ``LOG`` when searching in the
environment.
*oldname* is prefixed with :data:`~patroni.PATRONI_ENV_PREFIX` when searching in the environment.
If both *name* and *oldname* are set in the environment, *name* takes precedence.
:param name: new name of a log related environment variable.
:param oldname: original name of a log related environment variable.
:type oldname: str
"""
value = _popenv(oldname)
name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper()
if value and name not in os.environ:
@@ -390,6 +514,15 @@ class Config(object):
_fix_log_env(name, oldname)
def _set_section_values(section: str, params: List[str]) -> None:
"""Get value of *params* environment variables that are related with *section*.
.. note::
The values are retrieved from the environment and updated directly into the returning dictionary of
:func:`_build_environment_configuration`.
:param section: configuration section the *params* belong to.
:param params: name of the Patroni settings.
"""
for param in params:
value = _popenv(section + '_' + param)
if value:
@@ -402,8 +535,8 @@ class Config(object):
_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('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
'max_queue_size', 'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
@@ -411,6 +544,7 @@ class Config(object):
if value:
ret['postgresql'].setdefault('bin_name', {})[binary] = value
# parse all values retrieved from the environment as Python objects, according to the expected type
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
value = ret.get(first, {}).pop(second, None)
if value:
@@ -427,7 +561,13 @@ class Config(object):
if value is not None:
ret[first][second] = value
def _parse_list(value: str) -> Union[List[str], None]:
def _parse_list(value: str) -> Optional[List[str]]:
"""Parse an YAML list *value* as a :class:`list`.
:param value: YAML list as a string.
:returns: *value* as :class:`list`.
"""
if not (value.strip().startswith('-') or '[' in value):
value = '[{0}]'.format(value)
try:
@@ -443,7 +583,19 @@ class Config(object):
if value:
ret[first][second] = value
def _parse_dict(value: str) -> Union[Dict[str, Any], None]:
logformat = ret.get('log', {}).get('format')
if logformat and not re.search(r'%\(\w+\)', logformat):
logformat = _parse_list(logformat)
if logformat:
ret['log']['format'] = logformat
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
"""Parse an YAML dictionary *value* as a :class:`dict`.
:param value: YAML dictionary as a string.
:returns: *value* as :class:`dict`.
"""
if not value.strip().startswith('{'):
value = '{{{0}}}'.format(value)
try:
@@ -452,7 +604,12 @@ class Config(object):
logger.exception('Exception when parsing dict %s', value)
return None
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
dict_configs = (
('restapi', ('http_extra_headers', 'https_extra_headers')),
('log', ('static_fields', 'loggers'))
)
for first, params in dict_configs:
for second in params:
value = ret.get(first, {}).pop(second, None)
if value:
@@ -460,9 +617,16 @@ class Config(object):
if value:
ret[first][second] = value
def _get_auth(name: str, params: Optional[Collection[str]] = None) -> Dict[str, str]:
def _get_auth(name: str, params: Collection[str] = _AUTH_ALLOWED_PARAMETERS[:2]) -> Dict[str, str]:
"""Get authorization related environment variables *params* from section *name*.
:param name: name of a configuration section that may contain authorization *params*.
:param params: the authorization settings that may be set under section *name*.
:returns: dictionary containing environment values for authorization *params* of section *name*.
"""
ret: Dict[str, str] = {}
for param in params or _AUTH_ALLOWED_PARAMETERS[:2]:
for param in params:
value = _popenv(name + '_' + param)
if value:
ret[param] = value
@@ -485,14 +649,14 @@ class Config(object):
for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX):
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
name, suffix = (param[8:].split('_', 1) + [''])[:2]
name, suffix = (param[len(PATRONI_ENV_PREFIX):].split('_', 1) + [''])[:2]
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', 'SERVICE_CHECK_TLS_SERVER_NAME',
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL', 'AUTH_DATA') and name:
value = os.environ.pop(param)
if name == 'CITUS':
if suffix == 'GROUP':
@@ -503,7 +667,7 @@ class Config(object):
value = value and parse_int(value)
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
value = value and _parse_list(value)
elif suffix in ('LABELS', 'SET_ACLS'):
elif suffix in ('LABELS', 'SET_ACLS', 'AUTH_DATA'):
value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
value = parse_bool(value)
@@ -516,14 +680,14 @@ class Config(object):
users = {}
for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX):
name, suffix = (param[8:].rsplit('_', 1) + [''])[:2]
name, suffix = (param[len(PATRONI_ENV_PREFIX):].rsplit('_', 1) + [''])[:2]
# PATRONI_<username>_PASSWORD=<password>, PATRONI_<username>_OPTIONS=<option1,option2,...>
# CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>'
if name and suffix == 'PASSWORD':
password = os.environ.pop(param)
if password:
users[name] = {'password': password}
options = os.environ.pop(param[:-9] + '_OPTIONS', None)
options = os.environ.pop(param[:-9] + '_OPTIONS', None) # replace "_PASSWORD" with "_OPTIONS"
options = options and _parse_list(options)
if options:
users[name]['options'] = options
@@ -534,6 +698,16 @@ class Config(object):
def _build_effective_configuration(self, dynamic_configuration: Dict[str, Any],
local_configuration: Dict[str, Union[Dict[str, Any], Any]]) -> Dict[str, Any]:
"""Build effective configuration by merging *dynamic_configuration* and *local_configuration*.
.. note::
*local_configuration* takes precedence over *dynamic_configuration* if a setting is defined in both.
:param dynamic_configuration: Patroni dynamic configuration.
:param local_configuration: Patroni local configuration.
:returns: _description_
"""
config = self._safe_copy_dynamic_configuration(dynamic_configuration)
for name, value in local_configuration.items():
if name == 'citus': # remove invalid citus configuration
@@ -597,23 +771,68 @@ class Config(object):
return config
def get(self, key: str, default: Optional[Any] = None) -> Any:
"""Get effective value of ``key`` setting from Patroni configuration root.
Designed to work the same way as :func:`dict.get`.
:param key: name of the setting.
:param default: default value if *key* is not present in the effective configuration.
:returns: value of *key*, if present in the effective configuration, otherwise *default*.
"""
return self.__effective_configuration.get(key, default)
def __contains__(self, key: str) -> bool:
"""Check if setting *key* is present in the effective configuration.
Designed to work the same way as :func:`dict.__contains__`.
:param key: name of the setting to be checked.
:returns: ``True`` if setting *key* exists in effective configuration, else ``False``.
"""
return key in self.__effective_configuration
def __getitem__(self, key: str) -> Any:
"""Get value of setting *key* from effective configuration.
Designed to work the same way as :func:`dict.__getitem__`.
:param key: name of the setting.
:returns: value of setting *key*.
:raises:
:class:`KeyError`: if *key* is not present in effective configuration.
"""
return self.__effective_configuration[key]
def copy(self) -> Dict[str, Any]:
"""Get a deep copy of effective Patroni configuration.
:returns: a deep copy of the Patroni configuration.
"""
return deepcopy(self.__effective_configuration)
def get_global_config(self, cluster: Union[Cluster, None]) -> GlobalConfig:
"""Instantiate :class:`GlobalConfig` based on input.
def _validate_failover_tags(self) -> None:
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
Use the configuration from provided *cluster* (the most up-to-date) or from the
local cache if *cluster.config* is not initialized or doesn't have a valid config.
:param cluster: the currently known cluster state from DCS
:returns: :class:`GlobalConfig` object
.. note::
To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory
configuration is one where ``nofailover`` is ``True`` but ``failover_priority > 0``, or where
``nofailover`` is ``False``, but ``failover_priority <= 0``. Essentially, ``nofailover`` and
``failover_priority`` are communicating different things.
This checks for this edge case (which is a misconfiguration on the part of the user) and warns them.
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
bedrock source of truth)
"""
return get_global_config(cluster, self._dynamic_configuration)
tags = self.get('tags', {})
if 'nofailover' not in tags:
return
nofailover_tag = tags.get('nofailover')
failover_priority_tag = parse_int(tags.get('failover_priority'))
if failover_priority_tag is not None \
and (bool(nofailover_tag) is True and failover_priority_tag > 0
or bool(nofailover_tag) is False and failover_priority_tag <= 0):
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
+516
View File
@@ -0,0 +1,516 @@
"""patroni ``--generate-config`` machinery."""
import abc
import logging
import os
import psutil
import socket
import sys
import yaml
from getpass import getuser, getpass
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
from psycopg2 import cursor
from . import psycopg
from .collections import EMPTY_DICT
from .config import Config
from .exceptions import PatroniException
from .log import PatroniLogger
from .postgresql.config import ConfigHandler, parse_dsn
from .postgresql.misc import postgres_major_version_to_int
from .utils import get_major_version, parse_bool, patch_config, read_stripped
# Mapping between the libpq connection parameters and the environment variables.
# This dict should be kept in sync with `patroni.utils._AUTH_ALLOWED_PARAMETERS`
# (we use "username" in the Patroni config for some reason, other parameter names are the same).
_AUTH_ALLOWED_PARAMETERS_MAPPING = {
'user': 'PGUSER',
'password': 'PGPASSWORD',
'sslmode': 'PGSSLMODE',
'sslcert': 'PGSSLCERT',
'sslkey': 'PGSSLKEY',
'sslpassword': '',
'sslrootcert': 'PGSSLROOTCERT',
'sslcrl': 'PGSSLCRL',
'sslcrldir': 'PGSSLCRLDIR',
'gssencmode': 'PGGSSENCMODE',
'channel_binding': 'PGCHANNELBINDING'
}
NO_VALUE_MSG = '#FIXME'
def get_address() -> Tuple[str, str]:
"""Try to get hostname and the ip address for it returned by :func:`~socket.gethostname`.
.. note::
Can also return local ip.
:returns: tuple consisting of the hostname returned by :func:`~socket.gethostname`
and the first element in the sorted list of the addresses returned by :func:`~socket.getaddrinfo`.
Sorting guarantees it will prefer IPv4.
If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator.NO_VALUE_MSG`.
"""
hostname = None
try:
hostname = socket.gethostname()
return hostname, sorted(socket.getaddrinfo(hostname, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0),
key=lambda x: x[0])[0][4][0]
except Exception as err:
logging.warning('Failed to obtain address: %r', err)
return NO_VALUE_MSG, NO_VALUE_MSG
class AbstractConfigGenerator(abc.ABC):
"""Object representing the generated Patroni config.
:ivar output_file: full path to the output file to be used.
:ivar pg_major: integer representation of the major PostgreSQL version.
:ivar config: dictionary used for the generated configuration storage.
"""
_HOSTNAME, _IP = get_address()
def __init__(self, output_file: Optional[str]) -> None:
"""Set up the output file (if passed), helper vars and the minimal config structure.
:param output_file: full path to the output file to be used.
"""
self.output_file = output_file
self.pg_major = 0
self.config = self.get_template_config()
self.generate()
@classmethod
def get_template_config(cls) -> Dict[str, Any]:
"""Generate a template config for further extension (e.g. in the inherited classes).
:returns: dictionary with the values gathered from Patroni env, hopefully defined hostname and ip address
(otherwise set to :data:`~patroni.config_generator.NO_VALUE_MSG`), and some sane defaults.
"""
template_config: Dict[str, Any] = {
'scope': NO_VALUE_MSG,
'name': cls._HOSTNAME,
'restapi': {
'connect_address': cls._IP + ':8008',
'listen': cls._IP + ':8008'
},
'log': {
'type': PatroniLogger.DEFAULT_TYPE,
'level': PatroniLogger.DEFAULT_LEVEL,
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
'format': PatroniLogger.DEFAULT_FORMAT,
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
},
'postgresql': {
'data_dir': NO_VALUE_MSG,
'connect_address': cls._IP + ':5432',
'listen': cls._IP + ':5432',
'bin_dir': '',
'authentication': {
'superuser': {
'username': 'postgres',
'password': NO_VALUE_MSG
},
'replication': {
'username': 'replicator',
'password': NO_VALUE_MSG
}
}
},
'tags': {
'failover_priority': 1,
'noloadbalance': False,
'clonefrom': True,
'nosync': False,
'nostream': False,
}
}
dynamic_config = Config.get_default_config()
# to properly dump CaseInsensitiveDict as YAML later
dynamic_config['postgresql']['parameters'] = dict(dynamic_config['postgresql']['parameters'])
config = Config('', None).local_configuration # Get values from env
config.setdefault('bootstrap', {})['dcs'] = dynamic_config
config.setdefault('postgresql', {})
del config['bootstrap']['dcs']['standby_cluster']
patch_config(template_config, config)
return template_config
@abc.abstractmethod
def generate(self) -> None:
"""Generate config and store in :attr:`~AbstractConfigGenerator.config`."""
@staticmethod
def _format_block(block: Any, line_prefix: str = '') -> str:
"""Format a single YAML block.
.. note::
Optionally the formatted block could be indented with the *line_prefix*
:param block: the object that should be formatted to YAML.
:param line_prefix: is used for indentation.
:returns: a formatted and indented *block*.
"""
return line_prefix + yaml.safe_dump(block, default_flow_style=False, line_break='\n',
allow_unicode=True, indent=2).strip().replace('\n', '\n' + line_prefix)
def _format_config_section(self, section_name: str) -> Iterator[str]:
"""Format and yield as single section of the current :attr:`~AbstractConfigGenerator.config`.
.. note::
If the section is a :class:`dict` object we put an empty line before it.
:param section_name: a section name in the :attr:`~AbstractConfigGenerator.config`.
:yields: a formatted section in case if it exists in the :attr:`~AbstractConfigGenerator.config`.
"""
if section_name in self.config:
if isinstance(self.config[section_name], dict):
yield ''
yield self._format_block({section_name: self.config[section_name]})
def _format_config(self) -> Iterator[str]:
"""Format current :attr:`~AbstractConfigGenerator.config` and enrich it with some comments.
:yields: formatted lines or blocks that represent a text output of the YAML document.
"""
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
yield from self._format_config_section(name)
if 'bootstrap' in self.config:
yield '\n# The bootstrap configuration. Works only when the cluster is not yet initialized.'
yield '# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!'
yield 'bootstrap:'
if 'dcs' in self.config['bootstrap']:
yield ' # This section will be written into <dcs>:/<namespace>/<scope>/config after initializing'
yield ' # new cluster and all other cluster members will use it as a `global configuration`.'
yield ' # WARNING! If you want to change any of the parameters that were set up'
yield ' # via `bootstrap.dcs` section, please use `patronictl edit-config`!'
yield ' dcs:'
for name in ('loop_wait', 'retry_timeout', 'ttl'):
if name in self.config['bootstrap']['dcs']:
yield self._format_block({name: self.config['bootstrap']['dcs'].pop(name)}, ' ')
for name, value in self.config['bootstrap']['dcs'].items():
yield self._format_block({name: value}, ' ')
for name in ('postgresql', 'watchdog', 'tags'):
yield from self._format_config_section(name)
def _write_config_to_fd(self, fd: TextIO) -> None:
"""Format and write current :attr:`~AbstractConfigGenerator.config` to provided file descriptor.
:param fd: where to write the config file. Could be ``sys.stdout`` or the real file.
"""
fd.write('\n'.join(self._format_config()))
def write_config(self) -> None:
"""Write current :attr:`~AbstractConfigGenerator.config` to the output file if provided, to stdout otherwise."""
if self.output_file:
dir_path = os.path.dirname(self.output_file)
if dir_path and not os.path.isdir(dir_path):
os.makedirs(dir_path)
with open(self.output_file, 'w', encoding='UTF-8') as output_file:
self._write_config_to_fd(output_file)
else:
self._write_config_to_fd(sys.stdout)
class SampleConfigGenerator(AbstractConfigGenerator):
"""Object representing the generated sample Patroni config.
Sane defults are used based on the gathered PG version.
"""
@property
def get_auth_method(self) -> str:
"""Return the preferred authentication method for a specific PG version if provided or the default ``md5``.
:returns: :class:`str` value for the preferred authentication method.
"""
return 'scram-sha-256' if self.pg_major and self.pg_major >= 100000 else 'md5'
def _get_int_major_version(self) -> int:
"""Get major PostgreSQL version from the binary as an integer.
:returns: an integer PostgreSQL major version representation gathered from the PostgreSQL binary.
See :func:`~patroni.postgresql.misc.postgres_major_version_to_int` and
:func:`~patroni.utils.get_major_version`.
"""
postgres_bin = ((self.config.get('postgresql')
or EMPTY_DICT).get('bin_name') or EMPTY_DICT).get('postgres', 'postgres')
return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin))
def generate(self) -> None:
"""Generate sample config using some sane defaults and update :attr:`~AbstractConfigGenerator.config`."""
self.pg_major = self._get_int_major_version()
self.config['postgresql']['parameters'] = {'password_encryption': self.get_auth_method}
username = self.config["postgresql"]["authentication"]["replication"]["username"]
self.config['postgresql']['pg_hba'] = [
f'host all all all {self.get_auth_method}',
f'host replication {username} all {self.get_auth_method}'
]
# add version-specific configuration
wal_keep_param = 'wal_keep_segments' if self.pg_major < 130000 else 'wal_keep_size'
self.config['bootstrap']['dcs']['postgresql']['parameters'][wal_keep_param] = \
ConfigHandler.CMDLINE_OPTIONS[wal_keep_param][0]
wal_level = 'hot_standby' if self.pg_major < 90600 else 'replica'
self.config['bootstrap']['dcs']['postgresql']['parameters']['wal_level'] = wal_level
self.config['bootstrap']['dcs']['postgresql']['use_pg_rewind'] = True
if self.pg_major >= 110000:
self.config['postgresql']['authentication'].setdefault(
'rewind', {'username': 'rewind_user'}).setdefault('password', NO_VALUE_MSG)
class RunningClusterConfigGenerator(AbstractConfigGenerator):
"""Object representing the Patroni config generated using information gathered from the running instance.
:ivar dsn: DSN string for the local instance to get GUC values from (if provided).
:ivar parsed_dsn: DSN string parsed into a dictionary (see :func:`~patroni.postgresql.config.parse_dsn`).
"""
def __init__(self, output_file: Optional[str] = None, dsn: Optional[str] = None) -> None:
"""Additionally store the passed dsn (if any) in both original and parsed version and run config generation.
:param output_file: full path to the output file to be used.
:param dsn: DSN string for the local instance to get GUC values from.
:raises:
:exc:`~patroni.exceptions.PatroniException`: if DSN parsing failed.
"""
self.dsn = dsn
self.parsed_dsn = {}
super().__init__(output_file)
@property
def _get_hba_conn_types(self) -> Tuple[str, ...]:
"""Return the connection types allowed.
If :attr:`~RunningClusterConfigGenerator.pg_major` is defined, adds additional parameters
for PostgreSQL version >=16.
:returns: tuple of the connection methods allowed.
"""
allowed_types = ('local', 'host', 'hostssl', 'hostnossl', 'hostgssenc', 'hostnogssenc')
if self.pg_major and self.pg_major >= 160000:
allowed_types += ('include', 'include_if_exists', 'include_dir')
return allowed_types
@property
def _required_pg_params(self) -> List[str]:
"""PG configuration prameters that have to be always present in the generated config.
:returns: list of the parameter names.
"""
return ['hba_file', 'ident_file', 'config_file', 'data_directory'] + \
list(ConfigHandler.CMDLINE_OPTIONS.keys())
def _get_bin_dir_from_running_instance(self) -> str:
"""Define the directory postgres binaries reside using postmaster's pid executable.
:returns: path to the PostgreSQL binaries directory.
:raises:
:exc:`~patroni.exceptions.PatroniException`: if:
* pid could not be obtained from the ``postmaster.pid`` file; or
* :exc:`OSError` occured during ``postmaster.pid`` file handling; or
* the obtained postmaster pid doesn't exist.
"""
postmaster_pid = None
data_dir = self.config['postgresql']['data_dir']
try:
with open(f"{data_dir}/postmaster.pid", 'r') as pid_file:
postmaster_pid = pid_file.readline()
if not postmaster_pid:
raise PatroniException('Failed to obtain postmaster pid from postmaster.pid file')
postmaster_pid = int(postmaster_pid.strip())
except OSError as err:
raise PatroniException(f'Error while reading postmaster.pid file: {err}')
try:
return os.path.dirname(psutil.Process(postmaster_pid).exe())
except psutil.NoSuchProcess:
raise PatroniException("Obtained postmaster pid doesn't exist.")
@contextmanager
def _get_connection_cursor(self) -> Iterator[Union['cursor', 'Cursor[Any]']]:
"""Get cursor for the PG connection established based on the stored information.
:raises:
:exc:`~patroni.exceptions.PatroniException`: if :exc:`psycopg.Error` occured.
"""
try:
conn = psycopg.connect(dsn=self.dsn,
password=self.config['postgresql']['authentication']['superuser']['password'])
with conn.cursor() as cur:
yield cur
conn.close()
except psycopg.Error as e:
raise PatroniException(f'Failed to establish PostgreSQL connection: {e}')
def _set_pg_params(self, cur: Union['cursor', 'Cursor[Any]']) -> None:
"""Extend :attr:`~RunningClusterConfigGenerator.config` with the actual PG GUCs values.
THe following GUC values are set:
* Non-internal having configuration file, postmaster command line or environment variable
as a source.
* List of the always required parameters (see :meth:`~RunningClusterConfigGenerator._required_pg_params`).
:param cur: connection cursor to use.
"""
cur.execute("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings "
"WHERE context <> 'internal' "
"AND source IN ('configuration file', 'command line', 'environment variable') "
"AND category <> 'Write-Ahead Log / Recovery Target' "
"AND setting <> '(disabled)' "
"OR name = ANY(%s)", (self._required_pg_params,))
helper_dict = dict.fromkeys(['port', 'listen_addresses'])
self.config['postgresql'].setdefault('parameters', {})
for param, value in cur.fetchall():
if param == 'data_directory':
self.config['postgresql']['data_dir'] = value
elif param == 'cluster_name' and value:
self.config['scope'] = value
elif param in ('archive_command', 'restore_command',
'archive_cleanup_command', 'recovery_end_command',
'ssl_passphrase_command', 'hba_file',
'ident_file', 'config_file'):
# write commands to the local config due to security implications
# write hba/ident/config_file to local config to ensure they are not removed later
self.config['postgresql']['parameters'][param] = value
elif param in helper_dict:
helper_dict[param] = value
else:
self.config['bootstrap']['dcs']['postgresql']['parameters'][param] = value
connect_port = self.parsed_dsn.get('port', os.getenv('PGPORT', helper_dict['port']))
self.config['postgresql']['connect_address'] = f'{self._IP}:{connect_port}'
self.config['postgresql']['listen'] = f'{helper_dict["listen_addresses"]}:{helper_dict["port"]}'
def _set_su_params(self) -> None:
"""Extend :attr:`~RunningClusterConfigGenerator.config` with the superuser auth information.
Information set is based on the options used for connection.
"""
su_params: Dict[str, str] = {}
for conn_param, env_var in _AUTH_ALLOWED_PARAMETERS_MAPPING.items():
val = self.parsed_dsn.get(conn_param, os.getenv(env_var))
if val:
su_params[conn_param] = val
patroni_env_su_username = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('username')
patroni_env_su_pwd = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('password')
# because we use "username" in the config for some reason
su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser()
su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \
getpass('Please enter the user password:')
self.config['postgresql']['authentication'] = {
'superuser': su_params,
'replication': {'username': NO_VALUE_MSG, 'password': NO_VALUE_MSG}
}
def _set_conf_files(self) -> None:
"""Extend :attr:`~RunningClusterConfigGenerator.config` with ``pg_hba.conf`` and ``pg_ident.conf`` content.
.. note::
This function only defines ``postgresql.pg_hba`` and ``postgresql.pg_ident`` when
``hba_file`` and ``ident_file`` are set to the defaults. It may happen these files
are located outside of ``PGDATA`` and Patroni doesn't have write permissions for them.
:raises:
:exc:`~patroni.exceptions.PatroniException`: if :exc:`OSError` occured during the conf files handling.
"""
default_hba_path = os.path.join(self.config['postgresql']['data_dir'], 'pg_hba.conf')
if self.config['postgresql']['parameters']['hba_file'] == default_hba_path:
try:
self.config['postgresql']['pg_hba'] = list(
filter(lambda i: i and i.split()[0] in self._get_hba_conn_types, read_stripped(default_hba_path)))
except OSError as err:
raise PatroniException(f'Failed to read pg_hba.conf: {err}')
default_ident_path = os.path.join(self.config['postgresql']['data_dir'], 'pg_ident.conf')
if self.config['postgresql']['parameters']['ident_file'] == default_ident_path:
try:
self.config['postgresql']['pg_ident'] = [i for i in read_stripped(default_ident_path)
if i and not i.startswith('#')]
except OSError as err:
raise PatroniException(f'Failed to read pg_ident.conf: {err}')
if not self.config['postgresql']['pg_ident']:
del self.config['postgresql']['pg_ident']
def _enrich_config_from_running_instance(self) -> None:
"""Extend :attr:`~RunningClusterConfigGenerator.config` with the values gathered from the running instance.
Retrieve the following information from the running PostgreSQL instance:
* superuser auth parameters (see :meth:`~RunningClusterConfigGenerator._set_su_params`);
* some GUC values (see :meth:`~RunningClusterConfigGenerator._set_pg_params`);
* ``postgresql.connect_address``, ``postgresql.listen``;
* ``postgresql.pg_hba`` and ``postgresql.pg_ident`` (see :meth:`~RunningClusterConfigGenerator._set_conf_files`)
And redefine ``scope`` with the ``cluster_name`` GUC value if set.
:raises:
:exc:`~patroni.exceptions.PatroniException`: if the provided user doesn't have superuser privileges.
"""
self._set_su_params()
with self._get_connection_cursor() as cur:
self.pg_major = getattr(cur.connection, 'server_version', 0)
if not parse_bool(cur.connection.info.parameter_status('is_superuser')):
raise PatroniException('The provided user does not have superuser privilege')
self._set_pg_params(cur)
self._set_conf_files()
def generate(self) -> None:
"""Generate config using the info gathered from the specified running PG instance.
Result is written to :attr:`~RunningClusterConfigGenerator.config`.
"""
if self.dsn:
self.parsed_dsn = parse_dsn(self.dsn) or {}
if not self.parsed_dsn:
raise PatroniException('Failed to parse DSN string')
self._enrich_config_from_running_instance()
self.config['postgresql']['bin_dir'] = self._get_bin_dir_from_running_instance()
def generate_config(output_file: str, sample: bool, dsn: Optional[str]) -> None:
"""Generate Patroni configuration file.
:param output_file: Full path to the configuration file to be used. If not provided, result is sent to ``stdout``.
:param sample: Optional flag. If set, no source instance will be used - generate config with some sane defaults.
:param dsn: Optional DSN string for the local instance to get GUC values from.
"""
try:
if sample:
config_generator = SampleConfigGenerator(output_file)
else:
config_generator = RunningClusterConfigGenerator(output_file, dsn)
config_generator.write_config()
except PatroniException as e:
sys.exit(str(e))
except Exception as e:
sys.exit(f'Unexpected exception: {e}')
+210 -191
View File
File diff suppressed because it is too large Load Diff
+339 -271
View File
@@ -1,34 +1,33 @@
"""Abstract classes for Distributed Configuration Store."""
import abc
import datetime
import importlib
import inspect
import json
import logging
import os
import pkgutil
import re
import sys
import time
from collections import defaultdict
from copy import deepcopy
from random import randint
from threading import Event, Lock
from types import ModuleType
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Set, Tuple, Union, TYPE_CHECKING, \
Type, Iterator
from typing import Any, Callable, Collection, Dict, Iterator, List, \
NamedTuple, Optional, Tuple, Type, TYPE_CHECKING, Union
from urllib.parse import urlparse, urlunparse, parse_qsl
import dateutil.parser
from .. import global_config
from ..dynamic_loader import iter_classes, iter_modules
from ..exceptions import PatroniFatalException
from ..utils import deep_compare, uri
from ..tags import Tags
from ..utils import parse_int
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
from ..postgresql import Postgresql
from ..postgresql.mpp import AbstractMPP
CITUS_COORDINATOR_GROUP_ID = 0
citus_group_re = re.compile('^(0|[1-9][0-9]*)$')
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
logger = logging.getLogger(__name__)
@@ -84,28 +83,11 @@ def parse_connection_string(value: str) -> Tuple[str, Union[str, None]]:
def dcs_modules() -> List[str]:
"""Get names of DCS modules, depending on execution environment.
.. note::
If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because
:class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to
find all potential DCS modules by iterating through ``toc``, which contains list of all "frozen" resources.
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
"""
dcs_dirname = os.path.dirname(__file__)
module_prefix = __package__ + '.'
if getattr(sys, 'frozen', False):
toc: Set[str] = set()
# dcs_dirname may contain a dot, which causes pkgutil.iter_importers()
# to misinterpret the path as a package name. This can be avoided
# altogether by not passing a path at all, because PyInstaller's
# FrozenImporter is a singleton and registered as top-level finder.
for importer in pkgutil.iter_importers():
if hasattr(importer, 'toc'):
toc |= getattr(importer, 'toc')
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2]
return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg]
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
return iter_modules(__package__)
def iter_dcs_classes(
@@ -119,44 +101,18 @@ def iter_dcs_classes(
:param config: configuration information with possible DCS names as keys. If given, only attempt to import DCS
modules defined in the configuration. Else, if ``None``, attempt to import any supported DCS module.
:yields: a tuple containing the module ``name`` and the imported DCS class object.
:returns: an iterator of tuples, each containing the module ``name`` and the imported DCS class object.
"""
for mod_name in dcs_modules():
name = mod_name.rpartition('.')[2]
if config is None or name in config:
try:
module = importlib.import_module(mod_name)
dcs_module = find_dcs_class_in_module(module)
if dcs_module:
yield name, dcs_module
except ImportError:
logger.log(logging.DEBUG if config is not None else logging.INFO,
'Failed to import %s', mod_name)
def find_dcs_class_in_module(module: ModuleType) -> Optional[Type['AbstractDCS']]:
"""Try to find the implementation of :class:`AbstractDCS` interface in *module* matching the *module* name.
:param module: Imported DCS module.
:returns: class with a name matching the name of *module* that implements :class:`AbstractDCS` or ``None`` if not
found.
"""
module_name = module.__name__.rpartition('.')[2]
return next(
(obj for obj_name, obj in module.__dict__.items()
if (obj_name.lower() == module_name
and inspect.isclass(obj) and issubclass(obj, AbstractDCS))),
None)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
return iter_classes(__package__, AbstractDCS, config)
def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
"""Attempt to load a Distributed Configuration Store from known available implementations.
.. note::
Using the list of available DCS modules returned by :func:`iter_dcs_modules` attempt to dynamically import and
Using the list of available DCS classes returned by :func:`iter_classes` attempt to dynamically
instantiate the class that implements a DCS using the abstract class :class:`AbstractDCS`.
Basic top-level configuration parameters retrieved from *config* are propagated to the DCS specific config
@@ -177,25 +133,24 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
'patronictl', 'ttl', 'retry_timeout')
if p in config})
# From citus section we only need "group" parameter, but will propagate everything just in case.
if isinstance(config.get('citus'), dict):
config[name].update(config['citus'])
return dcs_class(config[name])
raise PatroniFatalException(
f"Can not find suitable configuration of distributed configuration store\n"
f"Available implementations: {', '.join(sorted([n for n, _ in iter_dcs_classes()]))}")
from patroni.postgresql.mpp import get_mpp
return dcs_class(config[name], get_mpp(config))
available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()]))
raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n"
f"Available implementations: {available_implementations}")
_Version = Union[int, str]
_Session = Union[int, float, str, None]
class Member(NamedTuple('Member',
[('version', _Version),
('name', str),
('session', _Session),
('data', Dict[str, Any])])):
class Member(Tags, NamedTuple('Member',
[('version', _Version),
('name', str),
('session', _Session),
('data', Dict[str, Any])])):
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
.. note::
@@ -316,20 +271,10 @@ class Member(NamedTuple('Member',
"""The ``tags`` value from :attr:`~Member.data` if defined, otherwise an empty dictionary."""
return self.data.get('tags', {})
@property
def nofailover(self) -> bool:
"""The value for ``nofailover`` in :attr:`Member`.tags`` if defined, otherwise ``False``."""
return self.tags.get('nofailover', False)
@property
def replicatefrom(self) -> Optional[str]:
"""The value for ``replicatefrom`` in :attr:`Member`.tags`` if defined."""
return self.tags.get('replicatefrom')
@property
def clonefrom(self) -> bool:
"""``True`` if both ``clonefrom`` tag is ``True`` and a connection URL is defined."""
return self.tags.get('clonefrom', False) and bool(self.conn_url)
return super().clonefrom and bool(self.conn_url)
@property
def state(self) -> str:
@@ -359,6 +304,11 @@ class Member(NamedTuple('Member',
logger.debug('Failed to parse Patroni version %s', version)
return None
@property
def lsn(self) -> Optional[int]:
"""Current LSN (receive/flush/replay)."""
return parse_int(self.data.get('xlog_location'))
class RemoteMember(Member):
"""Represents a remote member (typically a primary) for a standby cluster.
@@ -592,24 +542,6 @@ class ClusterConfig(NamedTuple):
modify_version = 0
return ClusterConfig(version, data, version if modify_version is None else modify_version)
@property
def permanent_slots(self) -> Dict[str, Any]:
"""Dictionary of permanent slots information looked up from :attr:`~ClusterConfig.data`."""
return (self.data.get('permanent_replication_slots')
or self.data.get('permanent_slots')
or self.data.get('slots')
or {})
@property
def ignore_slots_matchers(self) -> List[Dict[str, Any]]:
"""The value for ``ignore_slots`` from :attr:`~ClusterConfig.data` if defined or an empty list."""
return self.data.get('ignore_slots') or []
@property
def max_timelines_history(self) -> int:
"""The value for ``max_timelines_history`` from :attr:`~ClusterConfig.data` if defined or ``0``."""
return self.data.get('max_timelines_history', 0)
class SyncState(NamedTuple):
"""Immutable object (namedtuple) which represents last observed synchronous replication state.
@@ -628,7 +560,7 @@ class SyncState(NamedTuple):
"""Factory method to parse *value* as synchronisation state information.
:param version: optional *version* number for the object.
:param value: (optionally JSON serialised) sychronisation state information
:param value: (optionally JSON serialised) synchronisation state information
:returns: constructed :class:`SyncState` object.
@@ -787,19 +719,74 @@ class TimelineHistory(NamedTuple):
return TimelineHistory(version, value, lines)
class Status(NamedTuple):
"""Immutable object (namedtuple) which represents `/status` key.
Consists of the following fields:
:ivar last_lsn: :class:`int` object containing position of last known leader LSN.
:ivar slots: state of permanent replication slots on the primary in the format: ``{"slot_name": int}``.
"""
last_lsn: int
slots: Optional[Dict[str, int]]
@staticmethod
def empty() -> 'Status':
"""Construct an empty :class:`Status` instance.
:returns: empty :class:`Status` object.
"""
return Status(0, None)
@staticmethod
def from_node(value: Union[str, Dict[str, Any], None]) -> 'Status':
"""Factory method to parse *value* as :class:`Status` object.
:param value: JSON serialized string
:returns: constructed :class:`Status` object.
"""
try:
if isinstance(value, str):
value = json.loads(value)
except Exception:
return Status.empty()
if isinstance(value, int): # legacy
return Status(value, None)
if not isinstance(value, dict):
return Status.empty()
try:
last_lsn = int(value.get('optime', ''))
except Exception:
last_lsn = 0
slots: Union[str, Dict[str, int], None] = value.get('slots')
if isinstance(slots, str):
try:
slots = json.loads(slots)
except Exception:
slots = None
if not isinstance(slots, dict):
slots = None
return Status(last_lsn, slots)
class Cluster(NamedTuple('Cluster',
[('initialize', Optional[str]),
('config', Optional[ClusterConfig]),
('leader', Optional[Leader]),
('last_lsn', int),
('status', Status),
('members', List[Member]),
('failover', Optional[Failover]),
('sync', SyncState),
('history', Optional[TimelineHistory]),
('slots', Optional[Dict[str, int]]),
('failsafe', Optional[Dict[str, str]]),
('workers', Dict[int, 'Cluster'])])):
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
"""Immutable object (namedtuple) which represents PostgreSQL or MPP cluster.
.. note::
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
@@ -810,16 +797,14 @@ class Cluster(NamedTuple('Cluster',
:ivar initialize: shows whether this cluster has initialization key stored in DC or not.
:ivar config: global dynamic configuration, reference to `ClusterConfig` object.
:ivar leader: :class:`Leader` object which represents current leader of the cluster.
:ivar last_lsn: :class:int object containing position of last known leader LSN.
This value is stored in the `/status` key or `/optime/leader` (legacy) key.
:ivar status: :class:`Status` object which represents the `/status` key.
:ivar members: list of:class:` Member` objects, all PostgreSQL cluster members including leader
:ivar failover: reference to :class:`Failover` object.
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
:ivar history: reference to `TimelineHistory` object.
:ivar slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}.
:ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
the group, and the corresponding value is a :class:`Cluster` instance.
:ivar workers: dictionary of workers of the MPP cluster, optional. Each key representing the group and the
corresponding value is a :class:`Cluster` instance.
"""
def __new__(cls, *args: Any, **kwargs: Any):
@@ -828,10 +813,20 @@ class Cluster(NamedTuple('Cluster',
kwargs['workers'] = {}
return super(Cluster, cls).__new__(cls, *args, **kwargs)
@property
def last_lsn(self) -> int:
"""Last known leader LSN."""
return self.status.last_lsn
@property
def slots(self) -> Optional[Dict[str, int]]:
"""State of permanent replication slots on the primary in the format: ``{"slot_name": int}``."""
return self.status.slots
@staticmethod
def empty() -> 'Cluster':
"""Produce an empty :class:`Cluster` instance."""
return Cluster(None, None, None, 0, [], None, SyncState.empty(), None, None, None, {})
return Cluster(None, None, None, Status.empty(), [], None, SyncState.empty(), None, None, {})
def is_empty(self):
"""Validate definition of all attributes of this :class:`Cluster` instance.
@@ -854,7 +849,7 @@ class Cluster(NamedTuple('Cluster',
>>> assert bool(cluster) is False
>>> cluster = Cluster(None, None, None, 0, [1, 2, 3], None, SyncState.empty(), None, None, None, {})
>>> cluster = Cluster(None, None, None, Status(0, None), [1, 2, 3], None, SyncState.empty(), None, None, {})
>>> len(cluster)
1
@@ -910,64 +905,83 @@ class Cluster(NamedTuple('Cluster',
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
@staticmethod
def is_physical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
"""Check whether provided configuration is for permanent physical replication slot.
:param value: configuration of the permanent replication slot.
:returns: ``True`` if *value* is a physical replication slot, otherwise ``False``.
"""
return not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'
@staticmethod
def is_logical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
"""Check whether provided configuration is for permanent logical replication slot.
:param value: configuration of the permanent replication slot.
:returns: ``True`` if *value* is a logical replication slot, otherwise ``False``.
"""
return isinstance(value, dict) \
and value.get('type', 'logical') == 'logical' \
and bool(value.get('database') and value.get('plugin'))
@property
def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]:
"""Dictionary of permanent replication slots."""
return self.config and self.config.permanent_slots or {}
"""Dictionary of permanent replication slots with their known LSN."""
ret: Dict[str, Union[Dict[str, Any], Any]] = global_config.permanent_slots
members: Dict[str, int] = {slot_name_from_member_name(m.name): m.lsn or 0 for m in self.members}
slots: Dict[str, int] = {k: parse_int(v) or 0 for k, v in (self.slots or {}).items()}
for name, value in list(ret.items()):
if not value:
value = ret[name] = {}
if isinstance(value, dict):
# for permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the
# member with the matching name. It is necessary because we may have the replication slot on
# the primary that is streaming from the other standby node using the `replicatefrom` tag.
lsn = max(members.get(name, 0) if self.is_physical_slot(value) else 0, slots.get(name, 0))
if lsn:
value['lsn'] = lsn
else:
# Don't let anyone set 'lsn' in the global configuration :)
value.pop('lsn', None)
return ret
@property
def __permanent_physical_slots(self) -> Dict[str, Any]:
"""Dictionary of permanent ``physical`` replication slots."""
return {name: value for name, value in self.__permanent_slots.items()
if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'}
return {name: value for name, value in self.__permanent_slots.items() if self.is_physical_slot(value)}
@property
def __permanent_logical_slots(self) -> Dict[str, Any]:
"""Dictionary of permanent ``logical`` replication slots."""
return {name: value for name, value in self.__permanent_slots.items() if isinstance(value, dict)
and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')}
return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)}
@property
def use_slots(self) -> bool:
"""``True`` if cluster is configured to use replication slots."""
return bool(self.config and (self.config.data.get('postgresql') or {}).get('use_slots', True))
def get_replication_slots(self, my_name: str, role: str, nofailover: bool, major_version: int, *,
is_standby_cluster: bool = False, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
def get_replication_slots(self, postgresql: 'Postgresql', member: Tags, *,
role: Optional[str] = None, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
"""Lookup configured slot names in the DCS, report issues found and merge with permanent slots.
Will log an error if:
* Conflicting slot names between members are found
* Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``.
:param my_name: name of this node.
:param role: role of this node.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: postgresql major version.
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
the outside because we want to protect from the ``/config`` key removal.
:param postgresql: reference to :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface.
:param role: role of the node, if not set will be taken from *postgresql*.
:param show_error: if ``True`` report error if any disabled logical slots or conflicting slot names are found.
:returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks.
"""
slot_members: List[str] = self._get_slot_members(my_name, role)
name = member.name if isinstance(member, Member) else postgresql.name
role = role or postgresql.role
slots: Dict[str, Dict[str, str]] = {slot_name_from_member_name(name): {'type': 'physical'}
for name in slot_members}
slots: Dict[str, Dict[str, str]] = self._get_members_slots(name, role)
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
if len(slots) < len(slot_members):
# Find which names are conflicting for a nicer error message
slot_conflicts: Dict[str, List[str]] = defaultdict(list)
for name in slot_members:
slot_conflicts[slot_name_from_member_name(name)].append(name)
logger.error("Following cluster members share a replication slot name: %s",
"; ".join(f"{', '.join(v)} map to {k}"
for k, v in slot_conflicts.items() if len(v) > 1))
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster, role, nofailover)
disabled_permanent_logical_slots: List[str] = self._merge_permanent_slots(
slots, permanent_slots, my_name, major_version)
slots, permanent_slots, name, postgresql.major_version)
if disabled_permanent_logical_slots and show_error:
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
@@ -975,8 +989,7 @@ class Cluster(NamedTuple('Cluster',
return slots
@staticmethod
def _merge_permanent_slots(slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_name: str,
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], name: str,
major_version: int) -> List[str]:
"""Merge replication *slots* for members with *permanent_slots*.
@@ -986,7 +999,7 @@ class Cluster(NamedTuple('Cluster',
Type is assumed to be ``physical`` if there are no attributes stored as the slot value.
:param slots: Slot names with existing attributes if known.
:param my_name: name of this node.
:param name: name of this node.
:param permanent_slots: dictionary containing slot name key and slot information values.
:param major_version: postgresql major version.
@@ -994,9 +1007,9 @@ class Cluster(NamedTuple('Cluster',
"""
disabled_permanent_logical_slots: List[str] = []
for name, value in permanent_slots.items():
if not slot_name_re.match(name):
logger.error("Invalid permanent replication slot name '%s'", name)
for slot_name, value in permanent_slots.items():
if not slot_name_re.match(slot_name):
logger.error("Invalid permanent replication slot name '%s'", slot_name)
logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
continue
@@ -1007,120 +1020,171 @@ class Cluster(NamedTuple('Cluster',
if value['type'] == 'physical':
# Don't try to create permanent physical replication slot for yourself
if name != slot_name_from_member_name(my_name):
slots[name] = value
if slot_name != slot_name_from_member_name(name):
slots[slot_name] = value
continue
if value['type'] == 'logical' and value.get('database') and value.get('plugin'):
if major_version < 110000:
disabled_permanent_logical_slots.append(name)
elif name in slots:
if self.is_logical_slot(value):
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
disabled_permanent_logical_slots.append(slot_name)
elif slot_name in slots:
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with"
" physical replication slot for cluster member", name, value)
" physical replication slot for cluster member", slot_name, value)
else:
slots[name] = value
slots[slot_name] = value
continue
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
logger.error("Bad value for slot '%s' in permanent_slots: %s", slot_name, permanent_slots[slot_name])
return disabled_permanent_logical_slots
def _get_permanent_slots(self, is_standby_cluster: bool, role: str, nofailover: bool) -> Dict[str, Any]:
"""Get configured permanent slot names.
def _get_permanent_slots(self, postgresql: 'Postgresql', tags: Tags, role: str) -> Dict[str, Any]:
"""Get configured permanent replication slots.
.. note::
Permanent replication slots are only considered if ``use_slots`` configuration is enabled.
A node that is not supposed to become a leader (*nofailover*) will not have permanent replication slots.
Also node with disabled streaming (*nostream*) and its cascading followers must not have permanent
logical slots due to lack of feedback from node to primary, which makes them unsafe to use.
In a standby cluster we only support physical replication slots.
The returned dictionary for a non-standby cluster always contains permanent logical replication slots in
order to show a warning if they are not supported by PostgreSQL before v11.
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
the outside because we want to protect from the ``/config`` key removal.
:param role: role of this node -- ``primary``, ``standby_leader`` or ``replica``.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param postgresql: reference to :class:`Postgresql` object.
:param tags: reference to an object implementing :class:`Tags` interface.
:param role: role of the node -- ``primary``, ``standby_leader`` or ``replica``.
:returns: dictionary of permanent slot names mapped to attributes.
"""
if not self.use_slots or nofailover:
if not global_config.use_slots or tags.nofailover:
return {}
if is_standby_cluster:
return self.__permanent_physical_slots if role == 'standby_leader' else {}
if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
return self.__permanent_physical_slots \
if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
return self.__permanent_slots if role in ('master', 'primary') else self.__permanent_logical_slots
return self.__permanent_slots if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\
or role in ('master', 'primary') else self.__permanent_logical_slots
def _get_slot_members(self, my_name: str, role: str) -> List[str]:
"""Get a list of member names that have replication slots sourcing from this node.
def _get_members_slots(self, name: str, role: str) -> Dict[str, Dict[str, str]]:
"""Get physical replication slots configuration for members that sourcing from this node.
If the ``replicatefrom`` tag is set on the member - we should not create the replication slot for it on
the current primary, because that member would replicate from elsewhere. We still create the slot if
the ``replicatefrom`` destination member is currently not a member of the cluster (fallback to the
primary), or if ``replicatefrom`` destination member happens to be the current primary.
:param my_name: name of this node.
If the ``nostream`` tag is set on the member - we should not create the replication slot for it on
the current primary or any other member even if ``replicatefrom`` is set, because ``nostream`` disables
WAL streaming.
Will log an error if:
* Conflicting slot names between members are found
:param name: name of this node.
:param role: role of this node, if this is a ``primary`` or ``standby_leader`` return list of members
replicating from this node. If not then return a list of members replicating as cascaded
replicas from this node.
:returns: list of member names.
:returns: dictionary of physical replication slots that should exist on a given node.
"""
if not self.use_slots:
return []
if not global_config.use_slots:
return {}
# we always want to exclude the member with our name from the list,
# also exlude members with disabled WAL streaming
members = filter(lambda m: m.name != name and not m.nostream, self.members)
if role in ('master', 'primary', 'standby_leader'):
slot_members = [m.name for m in self.members
if m.name != my_name
and (m.replicatefrom is None
or m.replicatefrom == my_name
or not self.has_member(m.replicatefrom))]
members = [m for m in members if m.replicatefrom is None
or m.replicatefrom == name or not self.has_member(m.replicatefrom)]
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 m.replicatefrom == my_name and m.name != self.leader_name]
return slot_members
members = [m for m in members if m.replicatefrom == name and m.name != self.leader_name]
def has_permanent_logical_slots(self, my_name: str, nofailover: bool, major_version: int = 110000) -> bool:
slots = {slot_name_from_member_name(m.name): {'type': 'physical'} for m in members}
if len(slots) < len(members):
# Find which names are conflicting for a nicer error message
slot_conflicts: Dict[str, List[str]] = defaultdict(list)
for member in members:
slot_conflicts[slot_name_from_member_name(member.name)].append(member.name)
logger.error("Following cluster members share a replication slot name: %s",
"; ".join(f"{', '.join(v)} map to {k}"
for k, v in slot_conflicts.items() if len(v) > 1))
return slots
def has_permanent_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
"""Check if our node has permanent replication slots configured.
:param postgresql: reference to :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface for
the node that we are checking permanent logical replication slots for.
:returns: ``True`` if there are permanent replication slots configured, otherwise ``False``.
"""
role = 'replica'
members_slots: Dict[str, Dict[str, str]] = self._get_members_slots(postgresql.name, role)
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
slots = deepcopy(members_slots)
self._merge_permanent_slots(slots, permanent_slots, postgresql.name, postgresql.major_version)
return len(slots) > len(members_slots) or any(self.is_physical_slot(v) for v in permanent_slots.values())
def filter_permanent_slots(self, postgresql: 'Postgresql', slots: Dict[str, int]) -> Dict[str, int]:
"""Filter out all non-permanent slots from provided *slots* dict.
:param postgresql: reference to :class:`Postgresql` object.
:param slots: slot names with LSN values.
:returns: a :class:`dict` object that contains only slots that are known to be permanent.
"""
if postgresql.major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
return {} # for legacy PostgreSQL we don't support permanent slots on standby nodes
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, RemoteMember('', {}), 'replica')
members_slots = {slot_name_from_member_name(m.name) for m in self.members}
return {name: value for name, value in slots.items() if name in permanent_slots
and (self.is_physical_slot(permanent_slots[name])
or self.is_logical_slot(permanent_slots[name]) and name not in members_slots)}
def _has_permanent_logical_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
"""Check if the given member node has permanent ``logical`` replication slots configured.
:param my_name: name of the member node to check.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: the PostgreSQL major version number.
:param postgresql: reference to a :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface for
the node that we are checking permanent logical replication slots for.
:returns: ``False`` if PostgreSQL is < 11, ``True`` if any detected replications slots are ``logical``.
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
"""
if major_version < 110000:
return False
slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).values()
slots = self.get_replication_slots(postgresql, member, role='replica').values()
return any(v for v in slots if v.get("type") == "logical")
def should_enforce_hot_standby_feedback(self, my_name: str, nofailover: bool, major_version: int) -> bool:
def should_enforce_hot_standby_feedback(self, postgresql: 'Postgresql', member: Tags) -> bool:
"""Determine whether ``hot_standby_feedback`` should be enabled for the given member.
The ``hot_standby_feedback`` must be enabled if the current replica has ``logical`` slots,
or it is working as a cascading replica for the other node that has ``logical`` slots.
:param my_name: name of the member node to check.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: PostgreSQL major version number.
:param postgresql: reference to a :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface for
the node that we are checking permanent logical replication slots for.
:returns: ``True`` if this node or any member replicating from this node has permanent logical slots.
``False`` if PostgreSQL major version is < 11.
:returns: ``True`` if this node or any member replicating from this node has
permanent logical slots, otherwise ``False``.
"""
if major_version < 110000:
return False
if self.has_permanent_logical_slots(my_name, nofailover, major_version):
if self._has_permanent_logical_slots(postgresql, member):
return True
if self.use_slots:
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name]
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
if global_config.use_slots:
name = member.name if isinstance(member, Member) else postgresql.name
members = [m for m in self.members if m.replicatefrom == name and m.name != self.leader_name]
return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
return False
def get_my_slot_name_on_primary(self, my_name: str, replicatefrom: Optional[str]) -> str:
"""Canonical slot name for physical replication.
def get_slot_name_on_primary(self, name: str, tags: Tags) -> Optional[str]:
"""Get the name of physical replication slot for this node on the primary.
.. note::
P <-- I <-- L
@@ -1128,14 +1192,16 @@ class Cluster(NamedTuple('Cluster',
In case of cascading replication we have to check not our physical slot, but slot of the replica that
connects us to the primary.
:param my_name: the member node name that is replicating.
:param replicatefrom: the Intermediate member name that is configured to replicate for cascading replication.
:param name: name of the member node to check.
:param tags: reference to an object implementing :class:`Tags` interface.
:returns: The slot name that is in use for physical replication on this no`de.
:returns: the slot name on the primary that is in use for physical replication on this node.
"""
m = self.get_member(replicatefrom, False) if replicatefrom else None
return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) \
if isinstance(m, Member) else slot_name_from_member_name(my_name)
if tags.nostream:
return None
replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
@property
def timeline(self) -> int:
@@ -1148,19 +1214,20 @@ class Cluster(NamedTuple('Cluster',
:Example:
No history provided:
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0, None, {}).timeline
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, 0, None, {}).timeline
0
Empty history assume timeline is ``1``:
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0, None, {}).timeline
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[]'), None, {}).timeline
1
Invalid history format, a string of ``a``, returns ``0``:
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0, None, {}).timeline
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), None, {}).timeline
0
History as a list of strings:
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["3", "2", "1"]]'), 0, None, {}).timeline
>>> history = TimelineHistory.from_node(1, '[["3", "2", "1"]]')
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, history, None, {}).timeline
4
"""
if self.history:
@@ -1209,11 +1276,11 @@ class AbstractDCS(abc.ABC):
Functional methods that are critical in their timing, required to complete within ``retry_timeout`` period in order
to prevent the DCS considered inaccessible, each perform construction of complex data objects:
* :meth:`~AbstractDCS._cluster_loader`:
* :meth:`~AbstractDCS._postgresql_cluster_loader`:
method which processes the structure of data stored in the DCS used to build the :class:`Cluster` object
with all relevant associated data.
* :meth:`~AbstractDCS._citus_cluster_loader`:
Similar to above but specifically representing Citus group and workers information.
* :meth:`~AbstractDCS._mpp_cluster_loader`:
Similar to above but specifically representing MPP group and workers information.
* :meth:`~AbstractDCS._load_cluster`:
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
state and topology of the cluster.
@@ -1282,15 +1349,15 @@ class AbstractDCS(abc.ABC):
_SYNC = 'sync'
_FAILSAFE = 'failsafe'
def __init__(self, config: Dict[str, Any]) -> None:
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
"""Prepare DCS paths, MPP object, initial values for state information and processing dependencies.
:ivar config: :class:`dict`, reference to config section of selected DCS.
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
"""
self._mpp = mpp
self._name = config['name']
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
self._citus_group = str(config['group']) if isinstance(config.get('group'), int) else None
self._set_loop_wait(config.get('loop_wait', 10))
self._ctl = bool(config.get('patronictl', False))
@@ -1303,6 +1370,11 @@ class AbstractDCS(abc.ABC):
self._last_failsafe: Optional[Dict[str, str]] = {}
self.event = Event()
@property
def mpp(self) -> 'AbstractMPP':
"""Get the effective underlying MPP, if any has been configured."""
return self._mpp
def client_path(self, path: str) -> str:
"""Construct the absolute key name from appropriate parts for the DCS type.
@@ -1311,8 +1383,8 @@ class AbstractDCS(abc.ABC):
:returns: absolute key name for the current Patroni cluster.
"""
components = [self._base_path]
if self._citus_group:
components.append(self._citus_group)
if self._mpp.is_enabled():
components.append(str(self._mpp.group))
components.append(path.lstrip('/'))
return '/'.join(components)
@@ -1413,22 +1485,21 @@ class AbstractDCS(abc.ABC):
return self._last_seen
@abc.abstractmethod
def _cluster_loader(self, path: Any) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single Patroni or Citus cluster.
def _postgresql_cluster_loader(self, path: Any) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load Cluster(s) from.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
@abc.abstractmethod
def _citus_cluster_loader(self, path: Any) -> Union[Cluster, Dict[int, Cluster]]:
"""Load and build all Patroni clusters from a single Citus cluster.
def _mpp_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all Citus groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values or a
:class:`Cluster` object representing the coordinator with filled `Cluster.workers` attribute.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
@abc.abstractmethod
@@ -1443,16 +1514,14 @@ class AbstractDCS(abc.ABC):
the :meth:`~AbstractDCS.get_cluster` method.
:param path: the path in DCS where to load Cluster(s) from.
:param loader: one of :meth:`~AbstractDCS._cluster_loader` or :meth:`~AbstractDCS._citus_cluster_loader`.
:param loader: one of :meth:`~AbstractDCS._postgresql_cluster_loader` or
:meth:`~AbstractDCS._mpp_cluster_loader`.
:raise: :exc:`~DCSError` in case of communication problems with DCS. If the current node was running as a
primary and exception raised, instance would be demoted.
"""
def _bypass_caches(self) -> None:
"""Used only in Zookeeper."""
def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster:
def __get_postgresql_cluster(self, path: Optional[str] = None) -> Cluster:
"""Low level method to load a :class:`Cluster` object from DCS.
:param path: optional client path in DCS backend to load from.
@@ -1461,63 +1530,59 @@ class AbstractDCS(abc.ABC):
"""
if path is None:
path = self.client_path('')
cluster = self._load_cluster(path, self._cluster_loader)
cluster = self._load_cluster(path, self._postgresql_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(cluster, Cluster)
return cluster
def is_citus_coordinator(self) -> bool:
""":class:`Cluster` instance has a Citus Coordinator group ID.
def is_mpp_coordinator(self) -> bool:
""":class:`Cluster` instance has a Coordinator group ID.
:returns: ``True`` if the given node is running as Citus Coordinator (``group=0``).
:returns: ``True`` if the given node is running as the MPP Coordinator.
"""
return self._citus_group == str(CITUS_COORDINATOR_GROUP_ID)
return self._mpp.is_coordinator()
def get_citus_coordinator(self) -> Optional[Cluster]:
"""Load the Patroni cluster for the Citus Coordinator.
def get_mpp_coordinator(self) -> Optional[Cluster]:
"""Load the PostgreSQL cluster for the MPP Coordinator.
.. note::
This method is only executed on the worker nodes (``group!=0``) to find the coordinator.
.. note::
This method is only executed on the worker nodes to find the coordinator.
:returns: Select :class:`Cluster` instance associated with the Citus Coordinator group ID.
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
"""
try:
return self.__get_patroni_cluster(f'{self._base_path}/{CITUS_COORDINATOR_GROUP_ID}/')
return self.__get_postgresql_cluster(f'{self._base_path}/{self._mpp.coordinator_group_id}/')
except Exception as e:
logger.error('Failed to load Citus coordinator cluster from %s: %r', self.__class__.__name__, e)
logger.error('Failed to load %s coordinator cluster from %s: %r',
self._mpp.type, self.__class__.__name__, e)
return None
def _get_citus_cluster(self) -> Cluster:
"""Load Citus cluster from DCS.
def _get_mpp_cluster(self) -> Cluster:
"""Load MPP cluster from DCS.
:returns: A Citus :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
:returns: A MPP :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
dict.
"""
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
if isinstance(groups, Cluster): # Zookeeper could return a cached version
cluster = groups
else:
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
cluster.workers.update(groups)
groups = self._load_cluster(self._base_path + '/', self._mpp_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(groups, dict)
cluster = groups.pop(self._mpp.coordinator_group_id, Cluster.empty())
cluster.workers.update(groups)
return cluster
def get_cluster(self, force: bool = False) -> Cluster:
"""Retrieve an appropriate cached or fresh view of DCS.
def get_cluster(self) -> Cluster:
"""Retrieve a fresh view of DCS.
.. note::
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
Caching is required to avoid overhead placed upon the REST API.
Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability.
:param force: a value of ``True`` will override Zookeeper caching features.
Returns either a PostgreSQL or MPP implementation of :class:`Cluster` depending on availability.
:returns:
"""
if force:
self._bypass_caches()
try:
cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
cluster = self._get_mpp_cluster() if self.is_mpp_coordinator() else self.__get_postgresql_cluster()
except Exception:
self.reset_cluster()
raise
@@ -1758,26 +1823,29 @@ class AbstractDCS(abc.ABC):
"""
@abc.abstractmethod
def _delete_leader(self) -> bool:
def _delete_leader(self, leader: Leader) -> bool:
"""Remove leader key from DCS.
This method should remove leader key if current instance is the leader.
:param leader: :class:`Leader` object with information about the leader.
:returns: ``True`` if successfully committed to DCS.
"""
def delete_leader(self, last_lsn: Optional[int] = None) -> bool:
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
"""Update ``optime/leader`` and voluntarily remove leader key from DCS.
This method should remove leader key if current instance is the leader.
:param leader: :class:`Leader` object with information about the leader.
:param last_lsn: latest checkpoint location in bytes.
:returns: boolean result of called abstract :meth:`~AbstractDCS._delete_leader`.
"""
if last_lsn:
self.write_status({self._OPTIME: last_lsn})
return self._delete_leader()
return bool(leader) and self._delete_leader(leader)
@abc.abstractmethod
def cancel_initialization(self) -> bool:
+66 -40
View File
@@ -15,9 +15,10 @@ from urllib3.exceptions import HTTPError
from urllib.parse import urlencode, urlparse, quote
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -141,6 +142,36 @@ class HTTPClient(object):
class ConsulClient(base.Consul):
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
Consul client with Patroni customisations.
.. note::
Parameters, *token*, *cert* and *ca_cert* are not passed to the parent class :class:`consul.base.Consul`.
Original class documentation,
*token* is an optional ``ACL token``. If supplied it will be used by
default for all requests made with this client session. It's still
possible to override this token by passing a token explicitly for a
request.
*consistency* sets the consistency mode to use by default for all reads
that support the consistency option. It's still possible to override
this by passing explicitly for a given request. *consistency* can be
either 'default', 'consistent' or 'stale'.
*dc* is the datacenter that this agent will communicate with.
By default, the datacenter of the host is used.
*verify* is whether to verify the SSL certificate for HTTPS requests
*cert* client side certificates for HTTPS requests
:param args: positional arguments to pass to :class:`consul.base.Consul`
:param kwargs: keyword arguments, with *cert*, *ca_cert* and *token* removed, passed to
:class:`consul.base.Consul`
"""
self._cert = kwargs.pop('cert', None)
self._ca_cert = kwargs.pop('ca_cert', None)
self.token = kwargs.get('token')
@@ -202,8 +233,8 @@ def service_name_from_scope_name(scope_name: str) -> str:
class Consul(AbstractDCS):
def __init__(self, config: Dict[str, Any]) -> None:
super(Consul, self).__init__(config)
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Consul, self).__init__(config, mpp)
self._base_path = self._base_path[1:]
self._scope = config['scope']
self._session = None
@@ -353,23 +384,8 @@ class Consul(AbstractDCS):
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
# get last known leader lsn and slots
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status['Value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['Value']
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status['Value'])
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -398,29 +414,42 @@ class Consul(AbstractDCS):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
@property
def _consistency(self) -> str:
return 'consistent' if self._ctl else self._client.consistency
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
if results is None:
raise NotFound
nodes = {}
return Cluster.empty()
nodes: Dict[str, Dict[str, Any]] = {}
for node in results:
node['Value'] = (node['Value'] or b'').decode('utf-8')
nodes[node['Key'][len(path):]] = node
return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
results: Optional[List[Dict[str, Any]]]
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
for node in results or []:
key = node['Key'][len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
node['Value'] = (node['Value'] or b'').decode('utf-8')
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -430,8 +459,6 @@ class Consul(AbstractDCS):
) -> Union[Cluster, Dict[int, Cluster]]:
try:
return loader(path)
except NotFound:
return Cluster.empty()
except Exception:
logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly')
@@ -551,14 +578,17 @@ class Consul(AbstractDCS):
try:
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
except InvalidSession:
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
self._session = None
retry.ensure_deadline(0)
if not retry.ensure_deadline(0):
logger.error('Our session disappeared from Consul. Deadline exceeded, giving up')
return False
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
retry(self._do_refresh_session)
retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
@catch_return_false_exception
@@ -643,12 +673,8 @@ class Consul(AbstractDCS):
return self._client.kv.put(self.history_path, value)
@catch_consul_errors
def _delete_leader(self) -> bool:
cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and\
cluster.leader.name == self._name and isinstance(cluster.leader.version, int):
return self._client.kv.delete(self.leader_path, cas=cluster.leader.version)
return True
def _delete_leader(self, leader: Leader) -> bool:
return self._client.kv.delete(self.leader_path, cas=int(leader.version))
@catch_consul_errors
def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
@@ -657,7 +683,7 @@ class Consul(AbstractDCS):
if ret: # We have no other choise, only read after write :(
if not retry.ensure_deadline(0.5):
return False
_, ret = self.retry(self._client.kv.get, self.sync_path)
_, ret = self.retry(self._client.kv.get, self.sync_path, consistency='consistent')
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
return ret['ModifyIndex']
return False
+35 -32
View File
@@ -21,9 +21,10 @@ from urllib.parse import urlparse
from urllib3 import Timeout
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
@@ -470,9 +471,9 @@ class EtcdClient(AbstractEtcdClientWithFailover):
class AbstractEtcd(AbstractDCS):
def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP, client_cls: Type[AbstractEtcdClientWithFailover],
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
super(AbstractEtcd, self).__init__(config)
super(AbstractEtcd, self).__init__(config, mpp)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=retry_errors_cls)
self._ttl = int(config.get('ttl') or 30)
@@ -645,8 +646,8 @@ def catch_etcd_errors(func: Callable[..., Any]) -> Any:
class Etcd(AbstractEtcd):
def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self.__do_not_watch = False
@property
@@ -677,23 +678,8 @@ class Etcd(AbstractEtcd):
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
# get last know leader lsn and slots
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status.value)
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn.value
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status.value)
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -722,19 +708,38 @@ class Etcd(AbstractEtcd):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: str) -> Cluster:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
try:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
except etcd.EtcdKeyNotFound:
return Cluster.empty()
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
return self._cluster_from_nodes(result.etcd_index, nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
try:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
except etcd.EtcdKeyNotFound:
return {}
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
for node in result.leaves:
key = node.key[len(result.key):].lstrip('/').split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
@@ -744,8 +749,6 @@ class Etcd(AbstractEtcd):
cluster = None
try:
cluster = loader(path)
except etcd.EtcdKeyNotFound:
cluster = Cluster.empty()
except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
self._has_failed = False
@@ -809,7 +812,7 @@ class Etcd(AbstractEtcd):
return bool(self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new)))
@catch_etcd_errors
def _delete_leader(self) -> bool:
def _delete_leader(self, leader: Leader) -> bool:
return bool(self._client.delete(self.leader_path, prevValue=self._name))
@catch_etcd_errors
+95 -76
View File
@@ -15,10 +15,11 @@ from urllib3.exceptions import ReadTimeoutError, ProtocolError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \
TimelineHistory, catch_return_false_exception, citus_group_re
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, catch_return_false_exception
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
from ..exceptions import DCSError, PatroniException
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
logger = logging.getLogger(__name__)
@@ -124,6 +125,10 @@ class AuthFailed(InvalidArgument):
error = "etcdserver: authentication failed, invalid user ID or password"
class AuthOldRevision(InvalidArgument):
error = "etcdserver: revision of auth store is old"
class PermissionDenied(Etcd3ClientError):
code = GRPCCode.PermissionDenied
error = "etcdserver: permission denied"
@@ -204,6 +209,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
ERROR_CLS = Etcd3Error
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
self._reauthenticate = False
self._token = None
self._cluster_version: Tuple[int, ...] = tuple()
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
@@ -282,7 +288,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
fields['retry'] = retry
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
def authenticate(self) -> bool:
def authenticate(self, *, retry: Optional[Retry] = None) -> bool:
if self._use_proxies and not self._cluster_version:
kwargs = self._prepare_common_parameters(1)
self._ensure_version_prefix(self._base_uri, **kwargs)
@@ -291,7 +297,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
logger.info('Trying to authenticate on Etcd...')
old_token, self._token = self._token, None
try:
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password})
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password}, retry)
except AuthNotEnabled:
logger.info('Etcd authentication is not enabled')
self._token = None
@@ -302,48 +308,64 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
self._token = response.get('token')
return old_token != self._token
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
def retry(ex: Exception) -> Any:
if self.username and self.password:
self.authenticate()
return func(self, *args, **kwargs)
else:
logger.fatal('Username or password not set, authentication is not possible')
raise ex
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
reauthenticated = False
exc = None
while True:
if self._reauthenticate:
if self.username and self.password:
self.authenticate(retry=retry)
self._reauthenticate = False
else:
msg = 'Username or password not set, authentication is not possible'
logger.fatal(msg)
raise exc or Etcd3Exception(msg)
reauthenticated = True
try:
return func(self, *args, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided
# PermissionDenied is raised on 3.0 and 3.1
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
return retry(e)
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
return retry(e)
try:
return func(self, *args, retry=retry, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided
# PermissionDenied is raised on 3.0 and 3.1
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
exc = e
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
exc = e
except AuthOldRevision as e:
logger.error('Auth token is for old revision of auth store')
exc = e
self._reauthenticate = True
if retry:
logger.error('retry = %s', retry)
retry.ensure_deadline(0.5, exc)
elif reauthenticated:
raise exc
@_handle_auth_errors
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
retry: Optional[Retry] = None) -> Dict[str, Any]:
*, retry: Optional[Retry] = None) -> Dict[str, Any]:
params = build_range_request(key, range_end)
params['serializable'] = serializable # For better performance. We can tolerate stale reads
return self.call_rpc('/kv/range', params, retry)
def prefix(self, key: str, serializable: bool = True, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.range(key, prefix_range_end(key), serializable, retry)
def prefix(self, key: str, serializable: bool = True, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.range(key, prefix_range_end(key), serializable, retry=retry)
@_handle_auth_errors
def lease_grant(self, ttl: int, retry: Optional[Retry] = None) -> str:
def lease_grant(self, ttl: int, *, retry: Optional[Retry] = None) -> str:
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
def lease_keepalive(self, ID: str, retry: Optional[Retry] = None) -> Optional[str]:
def lease_keepalive(self, ID: str, *, retry: Optional[Retry] = None) -> Optional[str]:
return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL')
@_handle_auth_errors
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = {'compare': [compare], 'success': [success]}
if failure:
fields['failure'] = [failure]
@@ -352,7 +374,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
@_handle_auth_errors
def put(self, key: str, value: str, lease: Optional[str] = None, create_revision: Optional[str] = None,
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = {'key': base64_encode(key), 'value': base64_encode(value)}
if lease:
fields['lease'] = lease
@@ -367,14 +389,14 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
@_handle_auth_errors
def deleterange(self, key: str, range_end: Union[bytes, str, None] = None,
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = build_range_request(key, range_end)
if mod_revision is None:
return self.call_rpc('/kv/deleterange', fields, retry)
compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']}
return self.txn(compare, {'request_delete_range': fields}, retry=retry)
def deleteprefix(self, key: str, retry: Optional[Retry] = None) -> Dict[str, Any]:
def deleteprefix(self, key: str, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.deleterange(key, prefix_range_end(key), retry=retry)
def watchrange(self, key: str, range_end: Union[bytes, str, None] = None,
@@ -574,12 +596,6 @@ class PatroniEtcd3Client(Etcd3Client):
super(PatroniEtcd3Client, self).set_base_uri(value)
self._restart_watcher()
def authenticate(self) -> bool:
ret = super(PatroniEtcd3Client, self).authenticate()
if ret:
self._restart_watcher()
return ret
def _wait_cache(self, timeout: float) -> None:
stop_time = time.time() + timeout
while self._kv_cache and not self._kv_cache.is_ready():
@@ -631,8 +647,8 @@ class PatroniEtcd3Client(Etcd3Client):
return ret
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry)
failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry=retry)
# Here we abuse the fact that the `failure` is only set in the call from update_leader().
# In all other cases the txn() call failure may be an indicator of a stale cache,
# and therefore we want to restart watcher.
@@ -643,8 +659,9 @@ class PatroniEtcd3Client(Etcd3Client):
class Etcd3(AbstractEtcd):
def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd3, self).__init__(config, mpp, PatroniEtcd3Client,
(DeadlineExceeded, Unavailable, FailedPrecondition))
self.__do_not_watch = False
self._lease = None
self._last_lease_refresh = 0
@@ -676,12 +693,12 @@ class Etcd3(AbstractEtcd):
if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time():
return False
if self._lease and not self._client.lease_keepalive(self._lease, retry):
if self._lease and not self._client.lease_keepalive(self._lease, retry=retry):
self._lease = None
ret = not self._lease
if ret:
self._lease = self._client.lease_grant(self._ttl, retry)
self._lease = self._client.lease_grant(self._ttl, retry=retry)
self._last_lease_refresh = time.time()
return ret
@@ -703,7 +720,11 @@ class Etcd3(AbstractEtcd):
@property
def cluster_prefix(self) -> str:
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
"""Construct the cluster prefix for the cluster.
:returns: path in the DCS under which we store information about this Patroni cluster.
"""
return self._base_path + '/' if self.is_mpp_coordinator() else self.client_path('')
@staticmethod
def member(node: Dict[str, str]) -> Member:
@@ -723,23 +744,8 @@ class Etcd3(AbstractEtcd):
history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
# get last know leader lsn and slots
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status['value'])
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -770,20 +776,32 @@ class Etcd3(AbstractEtcd):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
nodes = {node['key'][len(path):]: node
for node in self._client.get_cluster(path)
if node['key'].startswith(path)}
return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
path = self._base_path + '/'
for node in self._client.get_cluster(path):
key = node['key'][len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -835,14 +853,16 @@ class Etcd3(AbstractEtcd):
try:
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
except LeaseNotFound:
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
self._lease = None
retry.ensure_deadline(0)
if not retry.ensure_deadline(0):
logger.error('Our lease disappeared from Etcd. Deadline exceeded, giving up')
return False
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
_retry(self._do_refresh_lease)
retry.ensure_deadline(1, Etcd3Error('_do_attempt_to_acquire_leader timeout'))
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
@catch_return_false_exception
@@ -912,11 +932,10 @@ class Etcd3(AbstractEtcd):
return self.retry(self._client.put, self.initialize_path, sysid, create_revision='0' if create_new else None)
@catch_etcd_errors
def _delete_leader(self) -> bool:
cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.deleterange(self.leader_path, mod_revision=cluster.leader.version)
return True
def _delete_leader(self, leader: Leader) -> bool:
fields = build_range_request(self.leader_path)
compare = {'key': fields['key'], 'target': 'VALUE', 'value': base64_encode(self._name)}
return bool(self._client.txn(compare, {'request_delete_range': fields}))
@catch_etcd_errors
def cancel_initialization(self) -> bool:
+3 -2
View File
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Union
from . import Cluster
from .zookeeper import ZooKeeper
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import uri
@@ -66,10 +67,10 @@ class ExhibitorEnsembleProvider(object):
class Exhibitor(ZooKeeper):
def __init__(self, config: Dict[str, Any]) -> None:
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
interval = config.get('poll_interval', 300)
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}, mpp)
def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
+82 -52
View File
@@ -19,9 +19,10 @@ from urllib3.exceptions import HTTPError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..collections import EMPTY_DICT
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
Retry, RetryFailedError, tzutc, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
@@ -470,7 +471,7 @@ class K8sClient(object):
if len(args) == 3: # name, namespace, body
body = args[2]
elif action == 'create': # namespace, body
body = args[1]
body = args[1] # pyright: ignore [reportGeneralTypeIssues]
elif action == 'delete': # name, namespace
body = kwargs.pop('body', None)
else:
@@ -509,7 +510,7 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
@property
def sleeptime(self) -> Optional[int]:
try:
return int((self.headers or {}).get('retry-after', ''))
return int((self.headers or EMPTY_DICT).get('retry-after', ''))
except Exception:
return None
@@ -654,7 +655,7 @@ class ObjectCache(Thread):
obj = K8sObject(obj)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
new_value = (obj.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
else:
@@ -662,7 +663,7 @@ class ObjectCache(Thread):
if success and obj.get('kind') != 'Pod':
if old_value:
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
old_value = (old_value.metadata.annotations or EMPTY_DICT).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)
@@ -746,9 +747,7 @@ class ObjectCache(Thread):
class Kubernetes(AbstractDCS):
_CITUS_LABEL = 'citus-group'
def __init__(self, config: Dict[str, Any]) -> None:
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
self._labels = deepcopy(config['labels'])
self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
@@ -759,9 +758,9 @@ class Kubernetes(AbstractDCS):
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
self._tmp_role_label = config.get('tmp_role_label')
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
super(Kubernetes, self).__init__({**config, 'namespace': ''})
if self._citus_group:
self._labels[self._CITUS_LABEL] = self._citus_group
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
if self._mpp.is_enabled():
self._labels[self._mpp.k8s_group_label] = str(self._mpp.group)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=KubernetesRetriableException)
@@ -771,8 +770,7 @@ class Kubernetes(AbstractDCS):
except k8s_config.ConfigException:
k8s_config.load_kube_config(context=config.get('context', 'kind-kind'))
pod_ip = config.get('pod_ip')
self.__ips: List[str] = [] if self._ctl or not isinstance(pod_ip, str) else [pod_ip]
self.__ips: List[str] = [] if self._ctl else [config.get('pod_ip', '')]
self.__ports: List[K8sObject] = []
ports: List[Dict[str, Any]] = config.get('ports', [{}])
for p in ports:
@@ -847,7 +845,7 @@ class Kubernetes(AbstractDCS):
@staticmethod
def member(pod: K8sObject) -> Member:
annotations = pod.metadata.annotations or {}
annotations = pod.metadata.annotations or EMPTY_DICT
member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
member.data['pod_labels'] = pod.metadata.labels
return member
@@ -888,18 +886,8 @@ class Kubernetes(AbstractDCS):
self._leader_resource_version = metadata.resource_version if metadata else None
annotations: Dict[str, str] = metadata and metadata.annotations or {}
# get last known leader lsn
try:
last_lsn = int(annotations.get(self._OPTIME, ''))
except Exception:
last_lsn = 0
# get permanent slots state (confirmed_flush_lsn)
slots = annotations.get('slots')
try:
slots = json.loads(annotations.get('slots', ''))
except Exception:
slots = None
# get last known leader lsn and slots
status = Status.from_node(annotations)
# get failsafe topology
try:
@@ -938,29 +926,41 @@ class Kubernetes(AbstractDCS):
failover = nodes.get(path + self._FAILOVER)
metadata = failover and failover.metadata
failover = metadata and Failover.from_node(metadata.resource_version,
(metadata.annotations or {}).copy())
(metadata.annotations or EMPTY_DICT).copy())
# get synchronization state
sync = nodes.get(path + self._SYNC)
metadata = sync and sync.metadata
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: Dict[str, Any]) -> Cluster:
def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
def _citus_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))
for name, pod in path['pods'].items():
group = pod.metadata.labels.get(self._CITUS_LABEL)
if group and citus_group_re.match(group):
group = pod.metadata.labels.get(self._mpp.k8s_group_label)
if group and self._mpp.group_re.match(group):
clusters[group]['pods'][name] = pod
for name, kind in path['nodes'].items():
group = kind.metadata.labels.get(self._CITUS_LABEL)
if group and citus_group_re.match(group):
group = kind.metadata.labels.get(self._mpp.k8s_group_label)
if group and self._mpp.group_re.match(group):
clusters[group]['nodes'][name] = kind
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
for group, value in clusters.items()}
@@ -976,9 +976,9 @@ class Kubernetes(AbstractDCS):
with self._condition:
self._wait_caches(stop_time)
pods = {name: pod for name, pod in self._pods.copy().items()
if not group or pod.metadata.labels.get(self._CITUS_LABEL) == group}
if not group or pod.metadata.labels.get(self._mpp.k8s_group_label) == group}
nodes = {name: kind for name, kind in self._kinds.copy().items()
if not group or kind.metadata.labels.get(self._CITUS_LABEL) == group}
if not group or kind.metadata.labels.get(self._mpp.k8s_group_label) == group}
return loader({'group': group, 'pods': pods, 'nodes': nodes})
except Exception:
logger.exception('get_cluster')
@@ -987,17 +987,24 @@ class Kubernetes(AbstractDCS):
def _load_cluster(
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
group = self._citus_group if path == self.client_path('') else None
group = str(self._mpp.group) if self._mpp.is_enabled() and path == self.client_path('') else None
return self.__load_cluster(group, loader)
def get_citus_coordinator(self) -> Optional[Cluster]:
def get_mpp_coordinator(self) -> Optional[Cluster]:
"""Load the PostgreSQL cluster for the MPP Coordinator.
.. note::
This method is only executed on the worker nodes to find the coordinator.
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
"""
try:
ret = self.__load_cluster(str(CITUS_COORDINATOR_GROUP_ID), self._cluster_loader)
ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._postgresql_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(ret, Cluster)
return ret
except Exception as e:
logger.error('Failed to load Citus coordinator cluster from Kubernetes: %r', e)
logger.error('Failed to load %s coordinator cluster from Kubernetes: %r', self._mpp.type, e)
@staticmethod
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
@@ -1041,8 +1048,9 @@ class Kubernetes(AbstractDCS):
def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject:
# we want to re-use existing target_ref if possible
empty_addresses: List[K8sObject] = []
for subset in latest_subsets:
for address in subset.addresses or []:
for address in subset.addresses or empty_addresses:
if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name:
return address.target_ref
return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace,
@@ -1050,7 +1058,8 @@ class Kubernetes(AbstractDCS):
def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None:
leader = self._kinds.get(self.leader_path)
latest_subsets = leader and leader.subsets or []
empty_addresses: List[K8sObject] = []
latest_subsets = leader and leader.subsets or empty_addresses
if not ips:
# We want to have subsets empty
if latest_subsets:
@@ -1069,6 +1078,27 @@ class Kubernetes(AbstractDCS):
def _patch_or_create(self, name: str, annotations: Dict[str, Any],
resource_version: Optional[str] = None, patch: bool = False,
retry: Optional[Callable[..., Any]] = None, ips: Optional[List[str]] = None) -> K8sObject:
"""Patch or create K8s object, Endpoint or ConfigMap.
:param name: the name of the object.
:param annotations: mapping of annotations that we want to create/update.
:param resource_version: object should be updated only if the ``resource_version`` matches provided value.
:param patch: ``True`` if we know in advance that the object already exists and we should patch it.
:param retry: a callable that will take care of retries
:param ips: IP address that we want to put to the subsets of the endpoint. Could have following values:
* ``None`` - when we don't need to touch subset;
* ``[]`` - to set subsets to the empty list, when :meth:`delete_leader` method is called;
* ``['ip.add.re.ss']`` - when we want to make sure that the subsets of the leader endpoint
contains the IP address of the leader, that we get from the ``kubernetes.pod_ip``;
* ``['']`` - when we want to make sure that the subsets of the leader endpoint contains the IP
address of the leader, but ``kubernetes.pod_ip`` configuration is missing. In this case we will
try to take the IP address of the Pod which name matches ``name`` from the config file.
:returns: the new :class:`V1Endpoints` or :class:`V1ConfigMap` object, that was created or updated.
"""
metadata = {'namespace': self._namespace, 'name': name, 'labels': self._labels, 'annotations': annotations}
if patch or resource_version:
if resource_version is not None:
@@ -1081,9 +1111,10 @@ class Kubernetes(AbstractDCS):
metadata['annotations'] = {k: v for k, v in annotations.items() if v is not None}
metadata = k8s_client.V1ObjectMeta(**metadata)
if ips is not None and self._api.use_endpoints:
if self._api.use_endpoints:
endpoints = {'metadata': metadata}
self._map_subsets(endpoints, ips)
if ips is not None:
self._map_subsets(endpoints, ips)
body = k8s_client.V1Endpoints(**endpoints)
else:
body = k8s_client.V1ConfigMap(metadata=metadata)
@@ -1184,7 +1215,7 @@ class Kubernetes(AbstractDCS):
if not retry.ensure_deadline(0.5):
return False
kind_annotations = kind and kind.metadata.annotations or {}
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
kind_resource_version = kind and kind.metadata.resource_version
# There is different leader or resource_version in cache didn't change
@@ -1197,7 +1228,7 @@ class Kubernetes(AbstractDCS):
def update_leader(self, leader: Leader, last_lsn: Optional[int],
slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool:
kind = self._kinds.get(self.leader_path)
kind_annotations = kind and kind.metadata.annotations or {}
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
if kind and kind_annotations.get(self._LEADER) != self._name:
return False
@@ -1232,11 +1263,10 @@ class Kubernetes(AbstractDCS):
else:
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
annotations['transitions'] = str(transitions)
ips: Optional[List[str]] = [] if self._api.use_endpoints else None
try:
ret = bool(self._patch_or_create(self.leader_path, annotations,
self._leader_resource_version, retry=self.retry, ips=ips))
self._leader_resource_version, retry=self.retry, ips=self.__ips))
except k8s_client.rest.ApiException as e:
if e.status == 409 and self._leader_resource_version: # Conflict in resource_version
# Terminate watchers, it could be a sign that K8s API is in a failed state
@@ -1312,14 +1342,14 @@ class Kubernetes(AbstractDCS):
if cluster and cluster.config and cluster.config.version else None
return self.patch_or_create_config({self._INITIALIZE: sysid}, resource_version)
def _delete_leader(self) -> bool:
def _delete_leader(self, leader: Leader) -> bool:
"""Unused"""
raise NotImplementedError # pragma: no cover
def delete_leader(self, last_lsn: Optional[int] = None) -> bool:
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
ret = False
kind = self._kinds.get(self.leader_path)
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
if kind and (kind.metadata.annotations or EMPTY_DICT).get(self._LEADER) == self._name:
annotations: Dict[str, Optional[str]] = {self._LEADER: None}
if last_lsn:
annotations[self._OPTIME] = str(last_lsn)
+23 -25
View File
@@ -12,8 +12,9 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE
from pysyncobj.utility import TcpUtility
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import validate_directory
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -284,8 +285,8 @@ class KVStoreTTL(DynMemberSyncObj):
class Raft(AbstractDCS):
def __init__(self, config: Dict[str, Any]) -> None:
super(Raft, self).__init__(config)
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Raft, self).__init__(config, mpp)
self._ttl = int(config.get('ttl') or 30)
ready_event = threading.Event()
@@ -343,23 +344,8 @@ class Raft(AbstractDCS):
history = history and TimelineHistory.from_node(history['index'], history['value'])
# get last know leader lsn and slots
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status['value'])
# get list of members
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -387,21 +373,33 @@ class Raft(AbstractDCS):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _cluster_loader(self, path: str) -> Cluster:
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
response = self._sync_obj.get(path, recursive=True)
if not response:
return Cluster.empty()
nodes = {key[len(path):]: value for key, value in response.items()}
return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
response = self._sync_obj.get(path, recursive=True)
for key, value in (response or {}).items():
key = key[len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]):
if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = value
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -446,7 +444,7 @@ class Raft(AbstractDCS):
def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new)) is not False
def _delete_leader(self) -> bool:
def _delete_leader(self, leader: Leader) -> bool:
return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1)
def cancel_initialization(self) -> bool:
+67 -115
View File
@@ -12,8 +12,9 @@ from kazoo.retry import RetryFailedError
from kazoo.security import ACL, make_acl
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -86,8 +87,8 @@ class PatroniKazooClient(KazooClient):
class ZooKeeper(AbstractDCS):
def __init__(self, config: Dict[str, Any]) -> None:
super(ZooKeeper, self).__init__(config)
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(ZooKeeper, self).__init__(config, mpp)
hosts: Union[str, List[str]] = config.get('hosts', [])
if isinstance(hosts, list):
@@ -114,11 +115,9 @@ class ZooKeeper(AbstractDCS):
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,
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
self._client.add_listener(self.session_listener)
deadline=config['retry_timeout'], sleep_func=time.sleep),
auth_data=list(config.get('auth_data', {}).items()), **kwargs)
self._fetch_cluster: bool = True
self._fetch_status: bool = True
self.__last_member_data: Optional[Dict[str, Any]] = None
self._orig_kazoo_connect = self._client._connection._connect
@@ -141,18 +140,9 @@ class ZooKeeper(AbstractDCS):
ret = self._orig_kazoo_connect(*args)
return max(self.loop_wait - 2, 2) * 1000, ret[1]
def session_listener(self, state: str) -> None:
if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None)
def status_watcher(self, event: Optional[WatchedEvent]) -> None:
self._fetch_status = True
self.event.set()
def cluster_watcher(self, event: Optional[WatchedEvent]) -> None:
self._fetch_cluster = True
if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.status_watcher(event)
def _watcher(self, event: WatchedEvent) -> None:
if event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.event.set()
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
self.set_retry_timeout(config['retry_timeout'])
@@ -200,138 +190,101 @@ class ZooKeeper(AbstractDCS):
except NoNodeError:
return None
def get_status(self, path: str, leader: Optional[Leader]) -> Tuple[int, Optional[Dict[str, int]]]:
watch = self.status_watcher if not leader or leader.name != self._name else None
status = self.get_node(path + self._STATUS, watch)
if status:
try:
status = json.loads(status[0])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = self.get_node(path + self._LEADER_OPTIME, watch)
last_lsn = last_lsn and last_lsn[0]
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
self._fetch_status = False
return last_lsn, slots
def get_status(self, path: str, leader: Optional[Leader]) -> Status:
status = self.get_node(path + self._STATUS)
if not status:
status = self.get_node(path + self._LEADER_OPTIME)
return Status.from_node(status and status[0])
@staticmethod
def member(name: str, value: str, znode: ZnodeStat) -> Member:
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
def get_children(self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None) -> List[str]:
def get_children(self, key: str) -> List[str]:
try:
return self._client.get_children(key, watch)
return self._client.get_children(key)
except NoNodeError:
return []
def load_members(self, path: str) -> List[Member]:
members: List[Member] = []
for member in self.get_children(path + self._MEMBERS, self.cluster_watcher):
for member in self.get_children(path + self._MEMBERS):
data = self.get_node(path + self._MEMBERS + member)
if data is not None:
members.append(self.member(member, *data))
return members
def _cluster_loader(self, path: str) -> Cluster:
self._fetch_cluster = False
self.event.clear()
nodes = set(self.get_children(path, self.cluster_watcher))
if not nodes:
self._fetch_cluster = True
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
nodes = set(self.get_children(path))
# get initialize flag
initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None
# get global dynamic configuration
config = self.get_node(path + self._CONFIG, watch=self.cluster_watcher) if self._CONFIG in nodes else None
config = self.get_node(path + self._CONFIG, watch=self._watcher) if self._CONFIG in nodes else None
config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
# get timeline history
history = self.get_node(path + self._HISTORY, watch=self.cluster_watcher) if self._HISTORY in nodes else None
history = self.get_node(path + self._HISTORY) if self._HISTORY in nodes else None
history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
# get synchronization state
sync = self.get_node(path + self._SYNC, watch=self.cluster_watcher) if self._SYNC in nodes else None
sync = self.get_node(path + self._SYNC) if self._SYNC in nodes else None
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
# get list of members
members = self.load_members(path) if self._MEMBERS[:-1] in nodes else []
# get leader
leader = self.get_node(path + self._LEADER) if self._LEADER in nodes else None
leader = self.get_node(path + self._LEADER, watch=self._watcher) if self._LEADER in nodes else None
if leader:
member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self._fetch_cluster = member.version == -1
# get last known leader lsn and slots
last_lsn, slots = self.get_status(path, leader)
status = self.get_status(path, leader)
# failover key
failover = self.get_node(path + self._FAILOVER, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
failover = self.get_node(path + self._FAILOVER) if self._FAILOVER in nodes else None
failover = failover and Failover.from_node(failover[1].version, failover[0])
# get failsafe topology
failsafe = self.get_node(path + self._FAILSAFE, watch=self.cluster_watcher) if self._FAILSAFE in nodes else None
failsafe = self.get_node(path + self._FAILSAFE) if self._FAILSAFE in nodes else None
try:
failsafe = json.loads(failsafe[0]) if failsafe else None
except Exception:
failsafe = None
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
fetch_cluster = False
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
ret: Dict[int, Cluster] = {}
for node in self.get_children(path, self.cluster_watcher):
if citus_group_re.match(node):
ret[int(node)] = self._cluster_loader(path + node + '/')
fetch_cluster = fetch_cluster or self._fetch_cluster
self._fetch_cluster = fetch_cluster
for node in self.get_children(path):
if self._mpp.group_re.match(node):
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
return ret
def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
cluster = self.cluster if path == self._base_path + '/' else None
if self._fetch_cluster or cluster is None:
try:
cluster = self._client.retry(loader, path)
except Exception:
logger.exception('get_cluster')
self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly')
# The /status ZNode was updated or doesn't exist
elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
# If current node is the leader just clear the event without fetching anything (we are updating the /status)
if cluster.leader and cluster.leader.name == self._name:
self.event.clear()
else:
try:
last_lsn, slots = self.get_status(self.client_path(''), cluster.leader)
self.event.clear()
new_cluster: List[Any] = list(cluster)
new_cluster[3] = last_lsn
new_cluster[8] = slots
cluster = Cluster(*new_cluster)
except Exception:
pass
return cluster
def _bypass_caches(self) -> None:
self._fetch_cluster = True
try:
return self._client.retry(loader, path)
except Exception:
logger.exception('get_cluster')
raise ZooKeeperError('ZooKeeper in not responding properly')
def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool:
try:
@@ -393,21 +346,17 @@ class ZooKeeper(AbstractDCS):
cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
member_data = self.__last_member_data or member and member.data
# We want to notify leader if some important fields in the member key changed by removing ZNode
if member and (self._client.client_id is not None and member.session != self._client.client_id[0]
or not (member_data and deep_compare(member_data.get('tags', {}), data.get('tags', {}))
and (member_data.get('state') == data.get('state')
or 'running' not in (member_data.get('state'), data.get('state')))
and member_data.get('version') == data.get('version')
and member_data.get('checkpoint_after_promote')
== data.get('checkpoint_after_promote'))):
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
pass
except Exception:
return False
member = None
if member and member_data:
# We want delete the member ZNode if our session doesn't match with session id on our member key
if self._client.client_id is not None and member.session != self._client.client_id[0]:
logger.warning('Recreating the member ZNode due to ownership mismatch')
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
pass
except Exception:
return False
member = None
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member and member_data:
@@ -466,7 +415,7 @@ class ZooKeeper(AbstractDCS):
return False
return True
def _delete_leader(self) -> bool:
def _delete_leader(self, leader: Leader) -> bool:
self._client.restart()
return True
@@ -499,7 +448,10 @@ class ZooKeeper(AbstractDCS):
return self.set_sync_state_value("{}", version) is not False
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
ret = super(ZooKeeper, self).watch(leader_version, timeout + 0.5)
if ret and not self._fetch_status:
self._fetch_cluster = True
return ret or self._fetch_cluster
if leader_version:
timeout += 0.5
try:
return super(ZooKeeper, self).watch(leader_version, timeout)
finally:
self.event.clear()
+96
View File
@@ -0,0 +1,96 @@
"""Helper functions to search for implementations of specific abstract interface in a package."""
import importlib
import inspect
import logging
import os
import pkgutil
import sys
from types import ModuleType
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Type, TypeVar, Union
if TYPE_CHECKING: # pragma: no cover
from .config import Config
logger = logging.getLogger(__name__)
def iter_modules(package: str) -> List[str]:
"""Get names of modules from *package*, depending on execution environment.
.. note::
If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because
:class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to
find all potential modules by iterating through ``toc``, which contains list of all "frozen" resources.
:param package: a package name to search modules in, e.g. ``patroni.dcs``.
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
"""
module_prefix = package + '.'
if getattr(sys, 'frozen', False):
toc: Set[str] = set()
# dirname may contain a few dots, which causes pkgutil.iter_importers()
# to misinterpret the path as a package name. This can be avoided
# altogether by not passing a path at all, because PyInstaller's
# FrozenImporter is a singleton and registered as top-level finder.
for importer in pkgutil.iter_importers():
if hasattr(importer, 'toc'):
toc |= getattr(importer, 'toc')
dots = module_prefix.count('.') # search for modules only on the same level
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == dots]
# here we are making an assumption that the package which is calling this function is already imported
pkg_file = sys.modules[package].__file__
if TYPE_CHECKING: # pragma: no cover
assert isinstance(pkg_file, str)
return [name for _, name, is_pkg in pkgutil.iter_modules([os.path.dirname(pkg_file)], module_prefix) if not is_pkg]
ClassType = TypeVar("ClassType")
def find_class_in_module(module: ModuleType, cls_type: Type[ClassType]) -> Optional[Type[ClassType]]:
"""Try to find the implementation of *cls_type* class interface in *module* matching the *module* name.
:param module: imported module.
:param cls_type: a class type we are looking for.
:returns: class with a name matching the name of *module* that implements *cls_type* or ``None`` if not found.
"""
module_name = module.__name__.rpartition('.')[2]
return next(
(obj for obj_name, obj in module.__dict__.items()
if (obj_name.lower() == module_name
and inspect.isclass(obj) and issubclass(obj, cls_type))),
None)
def iter_classes(
package: str, cls_type: Type[ClassType],
config: Optional[Union['Config', Dict[str, Any]]] = None
) -> Iterator[Tuple[str, Type[ClassType]]]:
"""Attempt to import modules and find implementations of *cls_type* that are present in the given configuration.
.. note::
If a module successfully imports we can assume that all its requirements are installed.
:param package: a package name to search modules in, e.g. ``patroni.dcs``.
:param cls_type: a class type we are looking for.
:param config: configuration information with possible module names as keys. If given, only attempt to import
modules defined in the configuration. Else, if ``None``, attempt to import any supported module.
:yields: a tuple containing the module ``name`` and the imported class object.
"""
for mod_name in iter_modules(package):
name = mod_name.rpartition('.')[2]
if config is None or name in config:
try:
module = importlib.import_module(mod_name)
module_cls = find_class_in_module(module, cls_type)
if module_cls:
yield name, module_cls
except ImportError:
logger.log(logging.DEBUG if config is not None else logging.INFO,
'Failed to import %s', mod_name)
+229
View File
@@ -0,0 +1,229 @@
"""Implements *global_config* facilities.
The :class:`GlobalConfig` object is instantiated on import and replaces
``patroni.global_config`` module in :data:`sys.modules`, what allows to use
its properties and methods like they were module variables and functions.
"""
import sys
import types
from copy import deepcopy
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .collections import EMPTY_DICT
from .utils import parse_bool, parse_int
if TYPE_CHECKING: # pragma: no cover
from .dcs import Cluster
def __getattr__(mod: types.ModuleType, name: str) -> Any:
"""This function exists just to make pyright happy.
Without it pyright complains about access to unknown members of global_config module.
"""
return getattr(sys.modules[__name__], name) # pragma: no cover
class GlobalConfig(types.ModuleType):
"""A class that wraps global configuration and provides convenient methods to access/check values."""
__file__ = __file__ # just to make unittest and pytest happy
def __init__(self) -> None:
"""Initialize :class:`GlobalConfig` object."""
super().__init__(__name__)
self.__config = {}
@staticmethod
def _cluster_has_valid_config(cluster: Optional['Cluster']) -> bool:
"""Check if provided *cluster* object has a valid global configuration.
:param cluster: the currently known cluster state from DCS.
:returns: ``True`` if provided *cluster* object has a valid global configuration, otherwise ``False``.
"""
return bool(cluster and cluster.config and cluster.config.modify_version)
def update(self, cluster: Optional['Cluster'], default: Optional[Dict[str, Any]] = None) -> None:
"""Update with the new global configuration from the :class:`Cluster` object view.
.. note::
Update happens in-place and is executed only from the main heartbeat thread.
:param cluster: the currently known cluster state from DCS.
:param default: default configuration, which will be used if there is no valid *cluster.config*.
"""
# Try to protect from the case when DCS was wiped out
if self._cluster_has_valid_config(cluster):
self.__config = cluster.config.data # pyright: ignore [reportOptionalMemberAccess]
elif default:
self.__config = default
def from_cluster(self, cluster: Optional['Cluster']) -> 'GlobalConfig':
"""Return :class:`GlobalConfig` instance from the provided :class:`Cluster` object view.
.. note::
If the provided *cluster* object doesn't have a valid global configuration we return
the last known valid state of the :class:`GlobalConfig` object.
This method is used when we need to have the most up-to-date values in the global configuration,
but we don't want to update the global object.
:param cluster: the currently known cluster state from DCS.
:returns: :class:`GlobalConfig` object.
"""
if not self._cluster_has_valid_config(cluster):
return self
ret = GlobalConfig()
ret.update(cluster)
return ret
def get(self, name: str) -> Any:
"""Gets global configuration value by *name*.
:param name: parameter name.
:returns: configuration value or ``None`` if it is missing.
"""
return self.__config.get(name)
def check_mode(self, mode: str) -> bool:
"""Checks whether the certain parameter is enabled.
:param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and
so on.
:returns: ``True`` if parameter *mode* is enabled in the global configuration.
"""
return bool(parse_bool(self.__config.get(mode)))
@property
def is_paused(self) -> bool:
"""``True`` if cluster is in maintenance mode."""
return self.check_mode('pause')
@property
def is_synchronous_mode(self) -> bool:
"""``True`` if synchronous replication is requested and it is not a standby cluster config."""
return self.check_mode('synchronous_mode') and not self.is_standby_cluster
@property
def is_synchronous_mode_strict(self) -> bool:
"""``True`` if at least one synchronous node is required."""
return self.check_mode('synchronous_mode_strict')
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
"""Get ``standby_cluster`` configuration.
:returns: a copy of ``standby_cluster`` configuration.
"""
return deepcopy(self.get('standby_cluster'))
@property
def is_standby_cluster(self) -> bool:
"""``True`` if global configuration has a valid ``standby_cluster`` section."""
config = self.get_standby_cluster_config()
return isinstance(config, dict) and\
bool(config.get('host') or config.get('port') or config.get('restore_command'))
def get_int(self, name: str, default: int = 0) -> int:
"""Gets current value of *name* from the global configuration and try to return it as :class:`int`.
:param name: name of the parameter.
:param default: default value if *name* is not in the configuration or invalid.
:returns: currently configured value of *name* from the global configuration or *default* if it is not set or
invalid.
"""
ret = parse_int(self.get(name))
return default if ret is None else ret
@property
def min_synchronous_nodes(self) -> int:
"""The minimum number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not."""
return 1 if self.is_synchronous_mode_strict else 0
@property
def synchronous_node_count(self) -> int:
"""Currently configured value of ``synchronous_node_count`` from the global configuration.
Assume ``1`` if it is not set or invalid.
"""
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
@property
def maximum_lag_on_failover(self) -> int:
"""Currently configured value of ``maximum_lag_on_failover`` from the global configuration.
Assume ``1048576`` if it is not set or invalid.
"""
return self.get_int('maximum_lag_on_failover', 1048576)
@property
def maximum_lag_on_syncnode(self) -> int:
"""Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration.
Assume ``-1`` if it is not set or invalid.
"""
return self.get_int('maximum_lag_on_syncnode', -1)
@property
def primary_start_timeout(self) -> int:
"""Currently configured value of ``primary_start_timeout`` from the global configuration.
Assume ``300`` if it is not set or invalid.
.. note::
``master_start_timeout`` is still supported to keep backward compatibility.
"""
default = 300
return self.get_int('primary_start_timeout', default)\
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
@property
def primary_stop_timeout(self) -> int:
"""Currently configured value of ``primary_stop_timeout`` from the global configuration.
Assume ``0`` if it is not set or invalid.
.. note::
``master_stop_timeout`` is still supported to keep backward compatibility.
"""
default = 0
return self.get_int('primary_stop_timeout', default)\
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
@property
def ignore_slots_matchers(self) -> List[Dict[str, Any]]:
"""Currently configured value of ``ignore_slots`` from the global configuration.
Assume an empty :class:`list` if not set.
"""
return self.get('ignore_slots') or []
@property
def max_timelines_history(self) -> int:
"""Currently configured value of ``max_timelines_history`` from the global configuration.
Assume ``0`` if not set or invalid.
"""
return self.get_int('max_timelines_history', 0)
@property
def use_slots(self) -> bool:
"""``True`` if cluster is configured to use replication slots."""
return bool(parse_bool((self.get('postgresql') or EMPTY_DICT).get('use_slots', True)))
@property
def permanent_slots(self) -> Dict[str, Any]:
"""Dictionary of permanent slots information from the global configuration."""
return deepcopy(self.get('permanent_replication_slots')
or self.get('permanent_slots')
or self.get('slots')
or EMPTY_DICT.copy())
sys.modules[__name__] = GlobalConfig()
+314 -198
View File
@@ -10,41 +10,38 @@ from multiprocessing.pool import ThreadPool
from threading import RLock
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
from . import psycopg
from . import global_config, psycopg
from .__main__ import Patroni
from .async_executor import AsyncExecutor, CriticalTask
from .collections import CaseInsensitiveSet
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember, Status, slot_name_from_member_name
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from .postgresql.callback_executor import CallbackAction
from .postgresql.misc import postgres_version_to_int
from .postgresql.postmaster import PostmasterProcess
from .postgresql.rewind import Rewind
from .tags import Tags
from .utils import polling_loop, tzutc
logger = logging.getLogger(__name__)
class _MemberStatus(NamedTuple):
"""Node status distilled from API response:
class _MemberStatus(Tags, NamedTuple('_MemberStatus',
[('member', Member),
('reachable', bool),
('in_recovery', Optional[bool]),
('wal_position', int),
('data', Dict[str, Any])])):
"""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)
watchdog_failed - indicates that watchdog is required by configuration but not available or failed
Consists of the following fields:
:ivar member: :class:`~patroni.dcs.Member` object of the node.
:ivar reachable: ``False`` if the node is not reachable or is not responding with correct JSON.
:ivar in_recovery: ``False`` if the node is running as a primary (`if pg_is_in_recovery() == true`).
:ivar wal_position: maximum value of ``replayed_location`` or ``received_location`` from JSON.
:ivar data: the whole JSON response for future usage.
"""
member: Member
reachable: bool
in_recovery: Optional[bool]
dcs_last_seen: int
timeline: int
wal_position: int
tags: Dict[str, Any]
watchdog_failed: bool
@classmethod
def from_api_response(cls, member: Member, json: Dict[str, Any]) -> '_MemberStatus':
@@ -57,21 +54,34 @@ class _MemberStatus(NamedTuple):
wal: Dict[str, Any] = json.get('wal') or json['xlog']
# abuse difference in primary/replica response format
in_recovery = not (bool(wal.get('location')) or json.get('role') in ('master', 'primary'))
timeline = json.get('timeline', 0)
dcs_last_seen = json.get('dcs_last_seen', 0)
lsn = int(in_recovery and max(wal.get('received_location', 0), wal.get('replayed_location', 0)))
return cls(member, True, in_recovery, dcs_last_seen, timeline, lsn,
json.get('tags', {}), json.get('watchdog_failed', False))
return cls(member, True, in_recovery, lsn, json)
@property
def tags(self) -> Dict[str, Any]:
"""Dictionary with values of different tags (i.e. nofailover)."""
return self.data.get('tags', {})
@property
def timeline(self) -> int:
"""Timeline value from JSON."""
return self.data.get('timeline', 0)
@property
def watchdog_failed(self) -> bool:
"""Indicates that watchdog is required by configuration but not available or failed."""
return self.data.get('watchdog_failed', False)
@classmethod
def unknown(cls, member: Member) -> '_MemberStatus':
return cls(member, False, None, 0, 0, 0, {}, False)
"""Create a new class instance with empty or null values."""
return cls(member, False, None, 0, {})
def failover_limitation(self) -> Optional[str]:
"""Returns reason why this node can't promote or None if everything is ok."""
if not self.reachable:
return 'not reachable'
if self.tags.get('nofailover', False):
if self.nofailover:
return 'not allowed to promote'
if self.watchdog_failed:
return 'not watchdog capable'
@@ -113,7 +123,8 @@ class Failsafe(object):
leader = self.leader
if leader:
# We rely on the strict order of fields in the namedtuple
cluster = Cluster(*cluster[0:2], leader, *cluster[3:8], leader.member.data['slots'], *cluster[9:])
status = Status(cluster.status.last_lsn, leader.member.data['slots'])
cluster = Cluster(*cluster[0:2], leader, status, *cluster[4:])
return cluster
def is_active(self) -> bool:
@@ -145,10 +156,9 @@ class Ha(object):
self._rewind = Rewind(self.state_handler)
self.dcs = patroni.dcs
self.cluster = Cluster.empty()
self.global_config = self.patroni.config.get_global_config(None)
self.old_cluster = Cluster.empty()
self._is_leader = False
self._is_leader_lock = RLock()
self._leader_expiry = 0
self._leader_expiry_lock = RLock()
self._failsafe = Failsafe(patroni.dcs)
self._was_paused = False
self._leader_timeline = None
@@ -165,7 +175,7 @@ class Ha(object):
# Count of concurrent sync disabling requests. Value above zero means that we don't want to be synchronous
# standby. Changes protected by _member_state_lock.
self._disable_sync = 0
# Remember the last known member role and state written to the DCS in order to notify Citus coordinator
# Remember the last known member role and state written to the DCS in order to notify MPP coordinator
self._last_state = None
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
@@ -175,30 +185,59 @@ class Ha(object):
# used only in backoff after failing a pre_promote script
self._released_leader_key_timestamp = 0
# Initialize global config
global_config.update(None, self.patroni.config.dynamic_configuration)
def primary_stop_timeout(self) -> Union[int, None]:
""":returns: "primary_stop_timeout" from the global configuration or `None` when not in synchronous mode."""
ret = self.global_config.primary_stop_timeout
ret = global_config.primary_stop_timeout
return ret if ret > 0 and self.is_synchronous_mode() else None
def is_paused(self) -> bool:
""":returns: `True` if in maintenance mode."""
return self.global_config.is_paused
return global_config.is_paused
def check_timeline(self) -> bool:
""":returns: `True` if should check whether the timeline is latest during the leader race."""
return self.global_config.check_mode('check_timeline')
return global_config.check_mode('check_timeline')
def is_standby_cluster(self) -> bool:
""":returns: `True` if global configuration has a valid "standby_cluster" section."""
return self.global_config.is_standby_cluster
return global_config.is_standby_cluster
def is_leader(self) -> bool:
with self._is_leader_lock:
return self._is_leader > time.time()
""":returns: `True` if the current node is the leader, based on expiration set when it last held the key."""
with self._leader_expiry_lock:
return self._leader_expiry > time.time()
def set_is_leader(self, value: bool) -> None:
with self._is_leader_lock:
self._is_leader = time.time() + self.dcs.ttl if value else 0
"""Update the current node's view of it's own leadership status.
Will update the expiry timestamp to match the dcs ttl if setting leadership to true,
otherwise will set the expiry to the past to immediately invalidate.
:param value: is the current node the leader.
"""
with self._leader_expiry_lock:
self._leader_expiry = time.time() + self.dcs.ttl if value else 0
def sync_mode_is_active(self) -> bool:
"""Check whether synchronous replication is requested and already active.
:returns: ``True`` if the primary already put its name into the ``/sync`` in DCS.
"""
return self.is_synchronous_mode() and not self.cluster.sync.is_empty
def _get_failover_action_name(self) -> str:
"""Return the currently requested manual failover action name or the default ``failover``.
:returns: :class:`str` representing the manually requested action (``manual failover`` if no leader
is specified in the ``/failover`` in DCS, ``switchover`` otherwise) or ``failover`` if
``/failover`` is empty.
"""
if not self.cluster.failover:
return 'failover'
return 'switchover' if self.cluster.failover.leader else 'manual failover'
def load_cluster_from_dcs(self) -> None:
cluster = self.dcs.get_cluster()
@@ -235,12 +274,31 @@ class Ha(object):
ret[self.state_handler.name] = self.patroni.api.connection_string
return ret
def update_lock(self, write_leader_optime: bool = False) -> bool:
def update_lock(self, update_status: bool = False) -> bool:
"""Update the leader lock in DCS.
.. note::
After successful update of the leader key the :meth:`AbstractDCS.update_leader` method could also
optionally update the ``/status`` and ``/failsafe`` keys.
The ``/status`` key contains the last known LSN on the leader node and the last known state
of permanent replication slots including permanent physical replication slot for the leader.
Last, but not least, this method calls a :meth:`Watchdog.keepalive` method after the leader key
was successfully updated.
:param update_status: ``True`` if we also need to update the ``/status`` key in DCS, otherwise ``False``.
:returns: ``True`` if the leader key was successfully updated and we can continue to run postgres
as a ``primary`` or as a ``standby_leader``, otherwise ``False``.
"""
last_lsn = slots = None
if write_leader_optime:
if update_status:
try:
last_lsn = self.state_handler.last_operation()
slots = self.state_handler.slots()
slots = self.cluster.filter_permanent_slots(
self.state_handler,
{**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn})
except Exception:
logger.exception('Exception when called state_handler.last_operation()')
if TYPE_CHECKING: # pragma: no cover
@@ -271,20 +329,26 @@ class Ha(object):
tags['nosync'] = True
return tags
def notify_citus_coordinator(self, event: str) -> None:
if self.state_handler.citus_handler.is_worker():
coordinator = self.dcs.get_citus_coordinator()
def notify_mpp_coordinator(self, event: str) -> None:
"""Send an event to the MPP coordinator.
:param event: the type of event for coordinator to parse.
"""
mpp_handler = self.state_handler.mpp_handler
if mpp_handler.is_worker():
coordinator = self.dcs.get_mpp_coordinator()
if coordinator and coordinator.leader and coordinator.leader.conn_url:
try:
data = {'type': event,
'group': self.state_handler.citus_handler.group(),
'group': mpp_handler.group,
'leader': self.state_handler.name,
'timeout': self.dcs.ttl,
'cooldown': self.patroni.config['retry_timeout']}
timeout = self.dcs.ttl if event == 'before_demote' else 2
self.patroni.request(coordinator.leader.member, 'post', 'citus', data, timeout=timeout, retries=0)
endpoint = 'citus' if mpp_handler.type == 'Citus' else 'mpp'
self.patroni.request(coordinator.leader.member, 'post', endpoint, data, timeout=timeout, retries=0)
except Exception as e:
logger.warning('Request to Citus coordinator leader %s %s failed: %r',
logger.warning('Request to %s coordinator leader %s %s failed: %r', mpp_handler.type,
coordinator.leader.name, coordinator.leader.member.api_url, e)
def touch_member(self) -> bool:
@@ -306,8 +370,9 @@ class Ha(object):
tags = self.get_effective_tags()
if tags:
data['tags'] = tags
if self.state_handler.pending_restart:
if self.state_handler.pending_restart_reason:
data['pending_restart'] = True
data['pending_restart_reason'] = dict(self.state_handler.pending_restart_reason)
if self._async_executor.scheduled_action in (None, 'promote') \
and data['state'] in ['running', 'restarting', 'starting']:
try:
@@ -347,7 +412,7 @@ class Ha(object):
if ret:
new_state = (data['state'], {'master': 'primary'}.get(data['role'], data['role']))
if self._last_state != new_state and new_state == ('running', 'primary'):
self.notify_citus_coordinator('after_promote')
self.notify_mpp_coordinator('after_promote')
self._last_state = new_state
return ret
@@ -393,7 +458,7 @@ class Ha(object):
return ret or 'trying to bootstrap {0}'.format(msg)
# no leader, but configuration may allowed replica creation using backup tools
create_replica_methods = self.global_config.get_standby_cluster_config().get('create_replica_methods', []) \
create_replica_methods = global_config.get_standby_cluster_config().get('create_replica_methods', []) \
if self.is_standby_cluster() else None
can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods)
concurrent_bootstrap = self.cluster.initialize == ""
@@ -468,11 +533,11 @@ class Ha(object):
:returns: action message, describing what was performed.
"""
if self.has_lock() and self.update_lock():
timeout = self.global_config.primary_start_timeout
timeout = global_config.primary_start_timeout
if timeout == 0:
# We are requested to prefer failing over to restarting primary. But see first if there
# is anyone to fail over to.
if self.is_failover_possible(self.cluster.members):
if self.is_failover_possible():
self.watchdog.disable()
logger.info("Primary crashed. Failing over.")
self.demote('immediate')
@@ -545,9 +610,14 @@ class Ha(object):
:returns: the node which we should be replicating from.
"""
# nostream is set, the node must not use WAL streaming
if self.patroni.nostream:
return None
# The standby leader or when there is no standby leader we want to follow
# the remote member, except when there is no standby leader in pause.
if self.is_standby_cluster() and (self.has_lock(False) or self.cluster.is_unlocked() and not self.is_paused()):
elif self.is_standby_cluster() \
and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name
or cluster.is_unlocked() and not self.is_paused()):
node_to_follow = self.get_remote_member()
# If replicatefrom tag is set, try to follow the node mentioned there, otherwise, follow the leader.
elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name:
@@ -563,7 +633,7 @@ class Ha(object):
for param in params: # It is highly unlikely to happen, but we want to protect from the case
node_to_follow.data.pop(param, None) # when above-mentioned params came from outside.
if self.is_standby_cluster():
standby_config = self.global_config.get_standby_cluster_config()
standby_config = global_config.get_standby_cluster_config()
node_to_follow.data.update({p: standby_config[p] for p in params if standby_config.get(p)})
return node_to_follow
@@ -572,7 +642,7 @@ class Ha(object):
if refresh:
self.load_cluster_from_dcs()
is_leader = self.state_handler.is_leader()
is_leader = self.state_handler.is_primary()
node_to_follow = self._get_node_to_follow(self.cluster)
@@ -625,11 +695,11 @@ class Ha(object):
def is_synchronous_mode(self) -> bool:
""":returns: `True` if synchronous replication is requested."""
return self.global_config.is_synchronous_mode
return global_config.is_synchronous_mode
def is_failsafe_mode(self) -> bool:
""":returns: `True` if failsafe_mode is enabled in global configuration."""
return self.global_config.check_mode('failsafe_mode')
return global_config.check_mode('failsafe_mode')
def process_sync_replication(self) -> None:
"""Process synchronous standby beahvior.
@@ -654,6 +724,14 @@ class Ha(object):
current = CaseInsensitiveSet(sync.members)
picked, allow_promote = self.state_handler.sync_handler.current_state(self.cluster)
if picked == current and current != allow_promote:
logger.warning('Inconsistent state between synchronous_standby_names = %s and /sync = %s key '
'detected, updating synchronous replication key...', list(allow_promote), list(current))
sync = self.dcs.write_sync_state(self.state_handler.name, allow_promote, version=sync.version)
if not sync:
return logger.warning("Updating sync state failed")
current = CaseInsensitiveSet(sync.members)
if picked != current:
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
sync_common = current & allow_promote
@@ -665,7 +743,7 @@ class Ha(object):
return logger.info('Synchronous replication key updated by someone else.')
# When strict mode and no suitable replication connections put "*" to synchronous_standby_names
if self.global_config.is_synchronous_mode_strict and not picked:
if global_config.is_synchronous_mode_strict and not picked:
picked = CaseInsensitiveSet('*')
logger.warning("No standbys available!")
@@ -738,7 +816,7 @@ class Ha(object):
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
if self.cluster.config:
history = history[-self.cluster.config.max_timelines_history:]
history = history[-global_config.max_timelines_history:]
for line in history:
# enrich current history with promotion timestamps stored in DCS
cluster_history_line = cluster_history_dict.get(line[0], [])
@@ -760,7 +838,7 @@ class Ha(object):
"""
if not self.is_paused():
if not self.watchdog.is_running and not self.watchdog.activate():
if self.state_handler.is_leader():
if self.state_handler.is_primary():
self.demote('immediate')
return 'Demoting self because watchdog could not be activated'
else:
@@ -776,13 +854,13 @@ class Ha(object):
self._async_response.reset()
return 'Promotion cancelled because the pre-promote script failed'
if self.state_handler.is_leader():
if self.state_handler.is_primary():
# Inform the state handler about its primary role.
# It may be unaware of it if postgres is promoted manually.
self.state_handler.set_role('master')
self.process_sync_replication()
self.update_cluster_history()
self.state_handler.citus_handler.sync_pg_dist_node(self.cluster)
self.state_handler.mpp_handler.sync_meta_data(self.cluster)
return message
elif self.state_handler.role in ('master', 'promoted', 'primary'):
self.process_sync_replication()
@@ -796,13 +874,13 @@ class Ha(object):
# promotion until next cycle. TODO: trigger immediate retry of run_cycle
return 'Postponing promotion because synchronous replication state was updated by somebody else'
self.state_handler.sync_handler.set_synchronous_standby_names(
CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
CaseInsensitiveSet('*') if global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
if self.state_handler.role not in ('master', 'promoted', 'primary'):
# reset failsafe state when promote
self._failsafe.set_is_active(0)
def before_promote():
self.notify_citus_coordinator('before_promote')
self.notify_mpp_coordinator('before_promote')
with self._async_response:
self._async_response.reset()
@@ -826,6 +904,8 @@ class Ha(object):
return _MemberStatus.unknown(member)
def fetch_nodes_statuses(self, members: List[Member]) -> List[_MemberStatus]:
if not members:
return []
pool = ThreadPool(len(members))
results = pool.map(self.fetch_node_status, members) # Run API calls on members in parallel
pool.close()
@@ -851,6 +931,26 @@ class Ha(object):
return False
def check_failsafe_topology(self) -> bool:
"""Check whether we could continue to run as a primary by calling all members from the failsafe topology.
.. note::
If the ``/failsafe`` key contains invalid data or if the ``name`` of our node is missing in
the ``/failsafe`` key, we immediately give up and return ``False``.
We send the JSON document in the POST request with the following fields:
* ``name`` - the name of our node;
* ``conn_url`` - connection URL to the postgres, which is reachable from other nodes;
* ``api_url`` - connection URL to Patroni REST API on this node reachable from other nodes;
* ``slots`` - a :class:`dict` with replication slots that exist on the leader node, including the primary
itself with the last known LSN, because there could be a permanent physical slot on standby nodes.
Standby nodes are using information from the ``slots`` dict to advance position of permanent
replication slots while DCS is not accessible in order to avoid indefinite growth of ``pg_wal``.
:returns: ``True`` if all members from the ``/failsafe`` topology agree that this node could continue to
run as a ``primary``, or ``False`` if some of standby nodes are not accessible or don't agree.
"""
failsafe = self.dcs.failsafe
if not isinstance(failsafe, dict) or self.state_handler.name not in failsafe:
return False
@@ -860,7 +960,10 @@ class Ha(object):
'api_url': self.patroni.api.connection_string,
}
try:
data['slots'] = self.state_handler.slots()
data['slots'] = {
**self.state_handler.slots(),
slot_name_from_member_name(self.state_handler.name): self.state_handler.last_operation()
}
except Exception:
logger.exception('Exception when called state_handler.slots()')
members = [RemoteMember(name, {'api_url': url})
@@ -882,7 +985,7 @@ class Ha(object):
:returns True when node is lagging
"""
lag = (self.cluster.last_lsn or 0) - wal_position
return lag > self.global_config.maximum_lag_on_failover
return lag > global_config.maximum_lag_on_failover
def _is_healthiest_node(self, members: Collection[Member], check_replication_lag: bool = True) -> bool:
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
@@ -905,51 +1008,60 @@ class Ha(object):
# Prepare list of nodes to run check against
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if members:
for st in self.fetch_nodes_statuses(members):
if st.failover_limitation() is None:
if st.in_recovery is False:
logger.warning('Primary (%s) is still alive', st.member.name)
for st in self.fetch_nodes_statuses(members):
if st.failover_limitation() is None:
if st.in_recovery is False:
logger.warning('Primary (%s) is still alive', st.member.name)
return False
if my_wal_position < st.wal_position:
logger.info('Wal position of %s is ahead of my wal position', st.member.name)
# In synchronous mode the former leader might be still accessible and even be ahead of us.
# We should not disqualify himself from the leader race in such a situation.
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
return False
if my_wal_position < st.wal_position:
logger.info('Wal position of %s is ahead of my wal position', st.member.name)
# In synchronous mode the former leader might be still accessible and even be ahead of us.
# We should not disqualify himself from the leader race in such a situation.
if not self.is_synchronous_mode() or self.cluster.sync.is_empty\
or not self.cluster.sync.leader_matches(st.member.name):
return False
logger.info('Ignoring the former leader being ahead of us')
logger.info('Ignoring the former leader being ahead of us')
if my_wal_position == st.wal_position and self.patroni.failover_priority < st.failover_priority:
# There's a higher priority non-lagging replica
logger.info(
'%s has equally tolerable WAL position and priority %s, while this node has priority %s',
st.member.name,
st.failover_priority,
self.patroni.failover_priority,
)
return False
return True
def is_failover_possible(self, members: List[Member], check_synchronous: Optional[bool] = True,
cluster_lsn: Optional[int] = 0) -> bool:
"""Checks whether one of the members from the list can possibly win the leader race.
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
"""Checks whether any of the cluster members is allowed to promote and is healthy enough for that.
:param members: list of members to check
:param check_synchronous: consider only members that are known to be listed in /sync key when sync replication.
:param cluster_lsn: to calculate replication lag and exclude member if it is laggin
:returns: `True` if there are members eligible to be the new leader
:param cluster_lsn: to calculate replication lag and exclude member if it is lagging.
:param exclude_failover_candidate: if ``True``, exclude :attr:`failover.candidate` from the members
list against which the failover possibility checks are run.
:returns: `True` if there are members eligible to become the new leader.
"""
candidates = self.get_failover_candidates(exclude_failover_candidate)
action = self._get_failover_action_name()
if self.is_synchronous_mode() and self.cluster.failover and self.cluster.failover.candidate and not candidates:
logger.warning('%s candidate=%s does not match with sync_standbys=%s',
action.title(), self.cluster.failover.candidate, self.cluster.sync.sync_standby)
elif not candidates:
logger.warning('%s: candidates list is empty', action)
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() and not self.cluster.sync.is_empty:
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 cluster_lsn and st.wal_position < cluster_lsn or\
not cluster_lsn and self.is_lagging(st.wal_position):
logger.info('Member %s exceeds maximum replication lag', st.member.name)
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
st.timeline, st.member.name, cluster_timeline)
else:
ret = True
else:
logger.warning('manual failover: members list is empty')
for st in self.fetch_nodes_statuses(candidates):
not_allowed_reason = st.failover_limitation()
if not_allowed_reason:
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
elif cluster_lsn and st.wal_position < cluster_lsn or \
not cluster_lsn and self.is_lagging(st.wal_position):
logger.info('Member %s exceeds maximum replication lag', st.member.name)
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
st.timeline, st.member.name, cluster_timeline)
else:
ret = True
return ret
def manual_failover_process_no_leader(self) -> Optional[bool]:
@@ -961,15 +1073,18 @@ class Ha(object):
failover = self.cluster.failover
if TYPE_CHECKING: # pragma: no cover
assert failover is not None
if failover.candidate: # manual failover to specific member
if failover.candidate == self.state_handler.name: # manual failover to me
action = self._get_failover_action_name()
if failover.candidate: # manual failover/switchover to specific member
if failover.candidate == self.state_handler.name: # manual failover/switchover to me
return True
elif self.is_paused():
# Remove failover key if the node to failover has terminated to avoid waiting for it indefinitely
# In order to avoid attempts to delete this key from all nodes only the primary is allowed to do it.
if not self.cluster.get_member(failover.candidate, fallback_to_leader=False)\
and self.state_handler.is_leader():
logger.warning("manual failover: removing failover key because failover candidate is not running")
and self.state_handler.is_primary():
logger.warning("%s: removing failover key because failover candidate is not running", action)
self.dcs.manual_failover('', '', version=failover.version)
return None
return False
@@ -985,22 +1100,21 @@ class Ha(object):
st = self.fetch_node_status(member)
not_allowed_reason = st.failover_limitation()
if not_allowed_reason is None: # node is healthy
logger.info('manual failover: to %s, i am %s', st.member.name, self.state_handler.name)
logger.info('%s: to %s, i am %s', action, st.member.name, self.state_handler.name)
return False
# we wanted to failover to specific member but it is not healthy
logger.warning('manual failover: member %s is %s', st.member.name, not_allowed_reason)
# we wanted to failover/switchover to specific member but it is not healthy
logger.warning('%s: member %s is %s', action, st.member.name, not_allowed_reason)
# at this point we should consider all members as a candidates for failover
# at this point we should consider all members as a candidates for failover/switchover
# i.e. we assume that failover.candidate is None
elif self.is_paused():
return False
# try to pick some other members to failover and check that they are healthy
# try to pick some other members for switchover and check that they are healthy
if failover.leader:
if self.state_handler.name == failover.leader: # I was the leader
# exclude me and desired member which is unhealthy (failover.candidate can be None)
members = [m for m in self.cluster.members if m.name not in (failover.candidate, failover.leader)]
if self.is_failover_possible(members): # check that there are healthy members
# exclude desired member which is unhealthy if it was specified
if self.is_failover_possible(exclude_failover_candidate=bool(failover.candidate)):
return False
else: # I was the leader and it looks like currently I am the only healthy member
return True
@@ -1028,7 +1142,7 @@ class Ha(object):
if ret is not None: # continue if we just deleted the stale failover key as a leader
return ret
if self.state_handler.is_leader():
if self.state_handler.is_primary():
if self.is_paused():
# in pause leader is the healthiest only when no initialize or sysid matches with initialize!
return not self.cluster.initialize or self.state_handler.sysid == self.cluster.initialize
@@ -1054,8 +1168,8 @@ class Ha(object):
if self.cluster.failover:
# When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race
if self.is_synchronous_mode() and self.cluster.failover.leader and \
not self.cluster.sync.is_empty and not self.cluster.sync.matches(self.state_handler.name, True):
if self.cluster.failover.leader and self.sync_mode_is_active() \
and not self.cluster.sync.matches(self.state_handler.name, True):
return False
return self.manual_failover_process_no_leader() or False
@@ -1076,7 +1190,7 @@ class Ha(object):
all_known_members += self.cluster.members
# When in sync mode, only last known primary and sync standby are allowed to promote automatically.
if self.is_synchronous_mode() and not self.cluster.sync.is_empty:
if self.sync_mode_is_active():
if not self.cluster.sync.matches(self.state_handler.name, True):
return False
# pick between synchronous candidates so we minimize unnecessary failovers/demotions
@@ -1089,7 +1203,7 @@ class Ha(object):
def _delete_leader(self, last_lsn: Optional[int] = None) -> None:
self.set_is_leader(False)
self.dcs.delete_leader(last_lsn)
self.dcs.delete_leader(self.cluster.leader, last_lsn)
self.dcs.reset_cluster()
def release_leader_key_voluntarily(self, last_lsn: Optional[int] = None) -> None:
@@ -1124,24 +1238,23 @@ class Ha(object):
status = {'released': False}
def on_shutdown(checkpoint_location: int) -> None:
def on_shutdown(checkpoint_location: int, prev_location: int) -> None:
# 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.
# for a manual failover/switchover with a candidate, we should check the requested candidate only
if self.is_failover_possible(self.get_failover_candidates(), cluster_lsn=checkpoint_location):
time.sleep(1) # give replicas some more time to catch up
if self.is_failover_possible(cluster_lsn=checkpoint_location):
self.state_handler.set_role('demoted')
with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
self.release_leader_key_voluntarily(prev_location)
status['released'] = True
def before_shutdown() -> None:
if self.state_handler.citus_handler.is_coordinator():
self.state_handler.citus_handler.on_demote()
if self.state_handler.mpp_handler.is_coordinator():
self.state_handler.mpp_handler.on_demote()
else:
self.notify_citus_coordinator('before_demote')
self.notify_mpp_coordinator('before_demote')
self.state_handler.stop(str(mode_control['stop']), checkpoint=bool(mode_control['checkpoint']),
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
@@ -1221,36 +1334,35 @@ class Ha(object):
:returns: action message if demote was initiated, None if no action was taken"""
failover = self.cluster.failover
if not failover or (self.is_paused() and not self.state_handler.is_leader()):
# if there is no failover key or
# I am holding the lock but am not primary = I am the standby leader,
# then do nothing
if not failover or (self.is_paused() and not self.state_handler.is_primary()):
return
action = self._get_failover_action_name()
bare_action = action.replace('manual ', '')
# it is not the time for the scheduled switchover yet, do nothing
if (failover.scheduled_at and not
self.should_run_scheduled_action("failover", failover.scheduled_at, lambda:
self.should_run_scheduled_action(bare_action, failover.scheduled_at, lambda:
self.dcs.manual_failover('', '', version=failover.version))):
return
if not failover.leader or failover.leader == self.state_handler.name:
if not failover.candidate or failover.candidate != self.state_handler.name:
if not failover.candidate and self.is_paused():
logger.warning('Failover is possible only to a specific candidate in a paused state')
logger.warning('%s is possible only to a specific candidate in a paused state', action.title())
elif self.is_failover_possible():
ret = self._async_executor.try_run_async(f'{action}: demote', self.demote, ('graceful',))
return ret or f'{action}: demoting myself'
else:
if self.is_synchronous_mode():
members = self.get_failover_candidates(check_sync=True)
if failover.candidate and not members:
logger.warning('Failover candidate=%s does not match with sync_standbys=%s',
failover.candidate, self.cluster.sync.sync_standby)
else:
members = self.get_failover_candidates()
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:
logger.warning('manual failover: no healthy members found, failover is not possible')
logger.warning('%s: no healthy members found, %s is not possible',
action, bare_action)
else:
logger.warning('manual failover: I am already the leader, no need to failover')
logger.warning('%s: I am already the leader, no need to %s', action, bare_action)
else:
logger.warning('manual failover: leader name does not match: %s != %s',
failover.leader, self.state_handler.name)
logger.warning('%s: leader name does not match: %s != %s', action, failover.leader, self.state_handler.name)
logger.info('Cleaning up failover key')
self.dcs.manual_failover('', '', version=failover.version)
@@ -1299,7 +1411,7 @@ class Ha(object):
def process_healthy_cluster(self) -> str:
if self.has_lock():
if self.is_paused() and not self.state_handler.is_leader():
if self.is_paused() and not self.state_handler.is_primary():
if self.cluster.failover and self.cluster.failover.candidate == self.state_handler.name:
return 'waiting to become primary after promote...'
@@ -1307,6 +1419,7 @@ class Ha(object):
self._delete_leader()
return 'removed leader lock because postgres is not running as primary'
# update lock to avoid split-brain
if self.update_lock(True):
msg = self.process_manual_failover_from_leader()
if msg is not None:
@@ -1331,7 +1444,7 @@ class Ha(object):
else:
# Either there is no connection to DCS or someone else acquired the lock
logger.error('failed to update leader lock')
if self.state_handler.is_leader():
if self.state_handler.is_primary():
if self.is_paused():
return 'continue to run as primary after failing to update leader lock in DCS'
self.demote('immediate-nolock')
@@ -1385,7 +1498,7 @@ class Ha(object):
if postgres_version and postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version):
reason_to_cancel = "postgres version mismatch"
if pending_restart and not self.state_handler.pending_restart:
if pending_restart and not self.state_handler.pending_restart_reason:
reason_to_cancel = "pending restart flag is not set"
if not reason_to_cancel:
@@ -1439,14 +1552,14 @@ class Ha(object):
# Now that restart is scheduled we can set timeout for startup, it will get reset
# once async executor runs and main loop notices PostgreSQL as up.
timeout = restart_data.get('timeout', self.global_config.primary_start_timeout)
timeout = restart_data.get('timeout', global_config.primary_start_timeout)
self.set_start_timeout(timeout)
def before_shutdown() -> None:
self.notify_citus_coordinator('before_demote')
self.notify_mpp_coordinator('before_demote')
def after_start() -> None:
self.notify_citus_coordinator('after_promote')
self.notify_mpp_coordinator('after_promote')
# For non async cases we want to wait for restart to complete or timeout before returning.
do_restart = functools.partial(self.state_handler.restart, timeout, self._async_executor.critical_task,
@@ -1503,8 +1616,8 @@ class Ha(object):
"""Figure out what to do with the task AsyncExecutor is performing."""
if self.has_lock() and self.update_lock():
if self._async_executor.scheduled_action == 'doing crash recovery in a single user mode':
time_left = self.global_config.primary_start_timeout - (time.time() - self._crash_recovery_started)
if time_left <= 0 and self.is_failover_possible(self.cluster.members):
time_left = global_config.primary_start_timeout - (time.time() - self._crash_recovery_started)
if time_left <= 0 and self.is_failover_possible():
logger.info("Demoting self because crash recovery is taking too long")
self.state_handler.cancellable.cancel(True)
self.demote('immediate')
@@ -1568,7 +1681,7 @@ class Ha(object):
self.cancel_initialization()
if result is None:
if not self.state_handler.is_leader():
if not self.state_handler.is_primary():
return 'waiting for end of recovery after bootstrap'
self.state_handler.set_role('master')
@@ -1588,7 +1701,7 @@ class Ha(object):
self.set_is_leader(True)
if self.is_synchronous_mode():
self.state_handler.sync_handler.set_synchronous_standby_names(
CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
CaseInsensitiveSet('*') if global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
self.state_handler.call_nowait(CallbackAction.ON_START)
self.load_cluster_from_dcs()
@@ -1611,11 +1724,11 @@ class Ha(object):
self.demote('immediate-nolock')
return 'stopped PostgreSQL while starting up because leader key was lost'
timeout = self._start_timeout or self.global_config.primary_start_timeout
timeout = self._start_timeout or global_config.primary_start_timeout
time_left = timeout - self.state_handler.time_in_state()
if time_left <= 0:
if self.is_failover_possible(self.cluster.members):
if self.is_failover_possible():
logger.info("Demoting self because primary startup is taking too long")
self.demote('immediate')
return 'stopped PostgreSQL because of startup timeout'
@@ -1644,8 +1757,8 @@ class Ha(object):
try:
try:
self.load_cluster_from_dcs()
self.global_config = self.patroni.config.get_global_config(self.cluster)
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover, self.global_config)
global_config.update(self.cluster)
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni)
except Exception:
self.state_handler.reset_cluster_info_state(None)
raise
@@ -1665,10 +1778,10 @@ class Ha(object):
self.touch_member()
# cluster has leader key but not initialize key
if not (self.cluster.is_unlocked() or self.sysid_valid(self.cluster.initialize)) and self.has_lock():
if self.has_lock(False) and not self.sysid_valid(self.cluster.initialize):
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
if not (self.cluster.is_unlocked() or self.cluster.config and self.cluster.config.data) and self.has_lock():
if self.has_lock(False) and not (self.cluster.config and self.cluster.config.data):
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
self.cluster = self.dcs.get_cluster()
@@ -1749,10 +1862,9 @@ class Ha(object):
logger.fatal('system ID mismatch, node %s belongs to a different cluster: %s != %s',
self.state_handler.name, self.cluster.initialize, data_sysid)
sys.exit(1)
elif self.cluster.is_unlocked() and not self.is_paused():
elif self.cluster.is_unlocked() and not self.is_paused() and not self.state_handler.cb_called:
# "bootstrap", but data directory is not empty
if not self.state_handler.cb_called and self.state_handler.is_running() \
and not self.state_handler.is_leader():
if self.state_handler.is_running() and not self.state_handler.is_primary():
self._join_aborted = True
logger.error('No initialize key in DCS and PostgreSQL is running as replica, aborting start')
logger.error('Please first start Patroni on the node running as primary')
@@ -1795,14 +1907,14 @@ class Ha(object):
create_slots = self._sync_replication_slots(False)
if not self.state_handler.cb_called:
if not is_promoting and not self.state_handler.is_leader():
if not is_promoting and not self.state_handler.is_primary():
self._rewind.trigger_check_diverged_lsn()
self.state_handler.call_nowait(CallbackAction.ON_START)
if not is_promoting and 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, create_slots))
args=(self.cluster, self.patroni, create_slots))
if not err:
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
return ret
@@ -1820,7 +1932,7 @@ class Ha(object):
def _handle_dcs_error(self) -> str:
if not self.is_paused() and self.state_handler.is_running():
if self.state_handler.is_leader():
if self.state_handler.is_primary():
if self.is_failsafe_mode() and self.check_failsafe_topology():
self.set_is_leader(True)
self._failsafe.set_is_active(time.time())
@@ -1858,10 +1970,7 @@ class Ha(object):
cluster = self._failsafe.update_cluster(self.cluster)\
if self.is_failsafe_mode() and not self.is_leader() else self.cluster
if cluster:
slots = self.state_handler.slots_handler.sync_replication_slots(cluster,
self.patroni.nofailover,
self.patroni.replicatefrom,
self.is_paused())
slots = self.state_handler.slots_handler.sync_replication_slots(cluster, self.patroni)
# Don't copy replication slots if failsafe_mode is active
return [] if self.failsafe_is_active() else slots
@@ -1889,22 +1998,21 @@ class Ha(object):
status = {'deleted': False}
def _on_shutdown(checkpoint_location: int) -> None:
def _on_shutdown(checkpoint_location: int, prev_location: int) -> None:
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.
# for a manual failover/switchover with a candidate, we should check the requested candidate only
if self.is_failover_possible(self.get_failover_candidates(), cluster_lsn=checkpoint_location):
self.dcs.delete_leader(checkpoint_location)
time.sleep(1) # give replicas some more time to catch up
if self.is_failover_possible(cluster_lsn=checkpoint_location):
self.dcs.delete_leader(self.cluster.leader, prev_location)
status['deleted'] = True
else:
self.dcs.write_leader_optime(checkpoint_location)
self.dcs.write_leader_optime(prev_location)
def _before_shutdown() -> None:
self.notify_citus_coordinator('before_demote')
self.notify_mpp_coordinator('before_demote')
on_shutdown = _on_shutdown if self.is_leader() else None
before_shutdown = _before_shutdown if self.is_leader() else None
@@ -1915,7 +2023,7 @@ class Ha(object):
if not self.state_handler.is_running():
if self.is_leader() and not status['deleted']:
checkpoint_location = self.state_handler.latest_checkpoint_location()
self.dcs.delete_leader(checkpoint_location)
self.dcs.delete_leader(self.cluster.leader, checkpoint_location)
self.touch_member()
else:
# XXX: what about when Patroni is started as the wrong user that has access to the watchdog device
@@ -1946,7 +2054,7 @@ class Ha(object):
config or cluster.config.data.
"""
data: Dict[str, Any] = {}
cluster_params = self.global_config.get_standby_cluster_config()
cluster_params = global_config.get_standby_cluster_config()
if cluster_params:
data.update({k: v for k, v in cluster_params.items() if k in RemoteMember.ALLOWED_KEYS})
@@ -1959,23 +2067,31 @@ class Ha(object):
name = member.name if member else 'remote_member:{}'.format(uuid.uuid1())
return RemoteMember(name, data)
def get_failover_candidates(self, check_sync: bool = False) -> List[Member]:
"""Return list of candidates for either manual or automatic failover.
def get_failover_candidates(self, exclude_failover_candidate: bool) -> List[Member]:
"""Return a list of candidates for either manual or automatic failover.
Mainly used to later be passed to ``Ha.is_failover_possible()``.
Exclude non-sync members when in synchronous mode, the current node (its checks are always performed earlier)
and the candidate if required. If failover candidate exclusion is not requested and a candidate is specified
in the /failover key, return the candidate only.
The result is further evaluated in the caller :func:`Ha.is_failover_possible` to check if any member is actually
healthy enough and is allowed to poromote.
:param check_sync: if ``True``, also check against the sync key members
:param exclude_failover_candidate: if ``True``, exclude :attr:`failover.candidate` from the candidates.
:returns: a list of ``Member`` ojects or an empty list if there is no candidate available
:returns: a list of :class:`Member` ojects or an empty list if there is no candidate available.
"""
failover = self.cluster.failover
if check_sync:
# TODO: allow manual failover (=no leader specified) to async node
# every sync_standby or the candidate specified if is in sync_standbys
return [m for m in self.cluster.members
if self.cluster.sync.matches(m.name)
and (not failover or not failover.candidate or m.name == failover.candidate)]
else:
# every member or the candidate specified
return [m for m in self.cluster.members
if not failover or not failover.candidate or m.name == failover.candidate]
exclude = [self.state_handler.name] + ([failover.candidate] if failover and exclude_failover_candidate else [])
def is_eligible(node: Member) -> bool:
# in synchronous mode we allow failover (not switchover!) to async node
if self.sync_mode_is_active() and not self.cluster.sync.matches(node.name)\
and not (failover and not failover.leader):
return False
# Don't spend time on "nofailover" nodes checking.
# We also don't need nodes which we can't query with the api in the list.
return node.name not in exclude and \
not node.nofailover and bool(node.api_url) and \
(not failover or not failover.candidate or node.name == failover.candidate)
return list(filter(is_eligible, self.cluster.members))
+189 -29
View File
@@ -9,12 +9,15 @@ import sys
from copy import deepcopy
from logging.handlers import RotatingFileHandler
from patroni.utils import deep_compare
from queue import Queue, Full
from threading import Lock, Thread
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .utils import deep_compare
type_logformat = Union[List[Union[str, Dict[str, Any], Any]], str, Any]
_LOGGER = logging.getLogger(__name__)
@@ -157,6 +160,7 @@ class PatroniLogger(Thread):
.. seealso::
:class:`QueueHandler`: object used for enqueueing messages in-memory.
:cvar DEFAULT_TYPE: default type of log format (``plain``).
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
@@ -169,6 +173,7 @@ class PatroniLogger(Thread):
:ivar log_handler_lock: lock used to modify ``log_handler``.
"""
DEFAULT_TYPE = 'plain'
DEFAULT_LEVEL = 'INFO'
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
@@ -202,28 +207,186 @@ class PatroniLogger(Thread):
self._proxy_handler = ProxyHandler(self)
self._root_logger.addHandler(self._proxy_handler)
def update_loggers(self) -> None:
"""Configure loggers' log level as defined in ``log.loggers`` section of Patroni configuration.
def update_loggers(self, config: Dict[str, Any]) -> None:
"""Configure custom loggers' log levels.
.. note::
It creates logger objects that are not defined yet in the log manager.
:param config: :class:`dict` object with custom loggers configuration, is set either from:
* ``log.loggers`` section of Patroni configuration; or
* from the method that is trying to make sure that the node name
isn't duplicated (to silence annoying ``urllib3`` WARNING's).
:Example:
.. code-block:: python
update_loggers({'urllib3.connectionpool': 'WARNING'})
"""
loggers = deepcopy((self._config or {}).get('loggers') or {})
loggers = deepcopy(config)
for name, logger in self._root_logger.manager.loggerDict.items():
# ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only
# in the ones that were defined
if not isinstance(logger, logging.PlaceHolder):
# if this logger is present in ``log.loggers`` Patroni configuration, use the configured level,
# otherwise use ``logging.NOTSET``, which means it will inherit the level from any parent node up to
# the root for which log level is defined.
# if this logger is present in *config*, use the configured level, otherwise
# use ``logging.NOTSET``, which means it will inherit the level
# from any parent node up to the root for which log level is defined.
level = loggers.pop(name, logging.NOTSET)
logger.setLevel(level)
# define loggers that do not exist yet and set level as configured in ``log.loggers`` section of configuration.
# define loggers that do not exist yet and set level as configured in the *config*
for name, level in loggers.items():
logger = self._root_logger.manager.getLogger(name)
logger.setLevel(level)
def _is_config_changed(self, config: Dict[str, Any]) -> bool:
"""Checks if the given config is different from the current one.
:param config: ``log`` section from Patroni configuration.
:returns: ``True`` if the config is changed, ``False`` otherwise.
"""
old_config = self._config or {}
oldlogtype = old_config.get('type', PatroniLogger.DEFAULT_TYPE)
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
oldlogformat: type_logformat = old_config.get('format', PatroniLogger.DEFAULT_FORMAT)
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
olddateformat = old_config.get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
old_static_fields = old_config.get('static_fields', {})
static_fields = config.get('static_fields', {})
old_log_config = {
'type': oldlogtype,
'format': oldlogformat,
'dateformat': olddateformat,
'static_fields': old_static_fields
}
log_config = {
'type': logtype,
'format': logformat,
'dateformat': dateformat,
'static_fields': static_fields
}
return not deep_compare(old_log_config, log_config)
def _get_plain_formatter(self, logformat: type_logformat, dateformat: Optional[str]) -> logging.Formatter:
"""Returns a logging formatter with the specified format and date format.
.. note::
If the log format isn't a string, prints a warning message and uses the default log format instead.
:param logformat: The format of the log messages.
:param dateformat: The format of the timestamp in the log messages.
:returns: A logging formatter object that can be used to format log records.
"""
if not isinstance(logformat, str):
_LOGGER.warning('Expected log format to be a string when log type is plain, but got "%s"', type(logformat))
logformat = PatroniLogger.DEFAULT_FORMAT
return logging.Formatter(logformat, dateformat)
def _get_json_formatter(self, logformat: type_logformat, dateformat: Optional[str],
static_fields: Dict[str, Any]) -> logging.Formatter:
"""Returns a logging formatter that outputs JSON formatted messages.
.. note::
If :mod:`pythonjsonlogger` library is not installed, prints an error message and returns
a plain log formatter instead.
:param logformat: Specifies the log fields and their key names in the JSON log message.
:param dateformat: The format of the timestamp in the log messages.
:param static_fields: A dictionary of static fields that are added to every log message.
:returns: A logging formatter object that can be used to format log records as JSON strings.
"""
if isinstance(logformat, str):
jsonformat = logformat
rename_fields = {}
elif isinstance(logformat, list):
log_fields: List[str] = []
rename_fields: Dict[str, str] = {}
for field in logformat:
if isinstance(field, str):
log_fields.append(field)
elif isinstance(field, dict):
for original_field, renamed_field in field.items():
if isinstance(renamed_field, str):
log_fields.append(original_field)
rename_fields[original_field] = renamed_field
else:
_LOGGER.warning(
'Expected renamed log field to be a string, but got "%s"',
type(renamed_field)
)
else:
_LOGGER.warning(
'Expected each item of log format to be a string or dictionary, but got "%s"',
type(field)
)
if len(log_fields) > 0:
jsonformat = ' '.join([f'%({field})s' for field in log_fields])
else:
jsonformat = PatroniLogger.DEFAULT_FORMAT
else:
jsonformat = PatroniLogger.DEFAULT_FORMAT
rename_fields = {}
_LOGGER.warning('Expected log format to be a string or a list, but got "%s"', type(logformat))
try:
from pythonjsonlogger import jsonlogger
return jsonlogger.JsonFormatter(
jsonformat,
dateformat,
rename_fields=rename_fields,
static_fields=static_fields
)
except ImportError as e:
_LOGGER.error('Failed to import "python-json-logger" library: %r. Falling back to the plain logger', e)
except Exception as e:
_LOGGER.error('Failed to initialize JsonFormatter: %r. Falling back to the plain logger', e)
return self._get_plain_formatter(jsonformat, dateformat)
def _get_formatter(self, config: Dict[str, Any]) -> logging.Formatter:
"""Returns a logging formatter based on the type of logger in the given configuration.
:param config: ``log`` section from Patroni configuration.
:returns: A :class:`logging.Formatter` object that can be used to format log records.
"""
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
dateformat = config.get('dateformat') or None # Convert empty string to `None`
static_fields = config.get('static_fields', {})
if dateformat is not None and not isinstance(dateformat, str):
_LOGGER.warning('Expected log dateformat to be a string, but got "%s"', type(dateformat))
dateformat = None
if logtype == 'json':
formatter = self._get_json_formatter(logformat, dateformat, static_fields)
else:
formatter = self._get_plain_formatter(logformat, dateformat)
return formatter
def reload_config(self, config: Dict[str, Any]) -> None:
"""Apply log related configuration.
@@ -244,37 +407,34 @@ class PatroniLogger(Thread):
# show stack traces as ``ERROR`` log messages
logging.Logger.exception = error_exception
new_handler = None
handler = self.log_handler
if 'dir' in config:
if not isinstance(self.log_handler, RotatingFileHandler):
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler = new_handler or self.log_handler
if TYPE_CHECKING: # pragma: no cover
assert isinstance(handler, RotatingFileHandler)
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
if not isinstance(handler, RotatingFileHandler):
handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
max_file_size = int(config.get('file_size', 25000000))
handler.maxBytes = max_file_size # pyright: ignore [reportAttributeAccessIssue]
handler.backupCount = int(config.get('file_num', 4))
else:
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
new_handler = logging.StreamHandler()
handler = new_handler or self.log_handler
# we can't use `if not isinstance(handler, logging.StreamHandler)` below,
# because RotatingFileHandler is a child of StreamHandler!!!
elif handler is None or isinstance(handler, RotatingFileHandler):
handler = logging.StreamHandler()
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT)
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
is_new_handler = handler != self.log_handler
olddateformat = (self._config or {}).get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
if (self._is_config_changed(config) or is_new_handler) and handler:
formatter = self._get_formatter(config)
handler.setFormatter(formatter)
if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
handler.setFormatter(logging.Formatter(logformat, dateformat))
if new_handler:
if is_new_handler:
with self.log_handler_lock:
if self.log_handler:
self._old_handlers.append(self.log_handler)
self.log_handler = new_handler
self.log_handler = handler
self._config = config.copy()
self.update_loggers()
self.update_loggers(config.get('loggers') or {})
def _close_old_handlers(self) -> None:
"""Close old log handlers.
+173 -132
View File
@@ -18,23 +18,23 @@ from .bootstrap import Bootstrap
from .callback_executor import CallbackAction, CallbackExecutor
from .cancellable import CancellableSubprocess
from .config import ConfigHandler, mtime
from .connection import Connection, get_connection_cursor
from .citus import CitusHandler
from .connection import ConnectionPool, get_connection_cursor
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
from .mpp import AbstractMPP
from .postmaster import PostmasterProcess
from .slots import SlotsHandler
from .sync import SyncHandler
from .. import psycopg
from .. import global_config, psycopg
from ..async_executor import CriticalTask
from ..collections import CaseInsensitiveSet
from ..dcs import Cluster, Leader, Member
from ..collections import CaseInsensitiveSet, CaseInsensitiveDict, EMPTY_DICT
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
from ..tags import Tags
if TYPE_CHECKING: # pragma: no cover
from psycopg import Connection as Connection3, Cursor
from psycopg2 import connection as connection3, cursor
from ..config import GlobalConfig
logger = logging.getLogger(__name__)
@@ -57,13 +57,13 @@ class Postgresql(object):
TL_LSN = ("CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
"ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, " # primary timeline
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
"ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END, " # write_lsn
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 ELSE "
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}{2}_{1}(), '0/0')::bigint END, " # wal(_flush)?_lsn
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, "
"pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, "
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
def __init__(self, config: Dict[str, Any]) -> None:
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
self.name: str = config['name']
self.scope: str = config['scope']
self._data_dir: str = config['data_dir']
@@ -73,14 +73,14 @@ class Postgresql(object):
self.connection_string: str
self.proxy_url: Optional[str]
self._major_version = self.get_major_version()
self._global_config = None
self._state_lock = Lock()
self.set_state('stopped')
self._pending_restart = False
self._connection = Connection()
self.citus_handler = CitusHandler(self, config.get('citus'))
self._pending_restart_reason = CaseInsensitiveDict()
self.connection_pool = ConnectionPool()
self._connection = self.connection_pool.get('heartbeat')
self.mpp_handler = mpp.get_handler_impl(self)
self.config = ConfigHandler(self, config)
self.config.check_directories()
@@ -111,24 +111,37 @@ class Postgresql(object):
self._state_entry_timestamp = 0
self._cluster_info_state = {}
self._has_permanent_logical_slots = True
self._has_permanent_slots = True
self._enforce_hot_standby_feedback = False
self._cached_replica_timeline = None
# Last known running process
self._postmaster_proc = None
if self.is_running(): # we are "joining" already running postgres
self.set_state('running')
self.set_role('master' if self.is_leader() else 'replica')
# postpone writing postgresql.conf for 12+ because recovery parameters are not yet known
if self.major_version < 120000 or self.is_leader():
self.config.write_postgresql_conf()
self._available_gucs = None
if self.is_running():
# If we found postmaster process we need to figure out whether postgres is accepting connections
self.set_state('starting')
self.check_startup_state_changed()
if self.state == 'running': # we are "joining" already running postgres
# we know that PostgreSQL is accepting connections and can read some GUC's from pg_settings
self.config.load_current_server_parameters()
self.set_role('master' if self.is_primary() else 'replica')
hba_saved = self.config.replace_pg_hba()
ident_saved = self.config.replace_pg_ident()
if hba_saved or ident_saved:
if self.major_version < 120000 or self.role in ('master', 'primary'):
# If PostgreSQL is running as a primary or we run PostgreSQL that is older than 12 we can
# call reload_config() once again (the first call happened in the ConfigHandler constructor),
# so that it can figure out if config files should be updated and pg_ctl reload executed.
self.config.reload_config(config, sighup=bool(hba_saved or ident_saved))
elif hba_saved or ident_saved:
self.reload()
elif self.role in ('master', 'primary'):
elif not self.is_running() and self.role in ('master', 'primary'):
self.set_role('demoted')
@property
@@ -159,6 +172,11 @@ class Postgresql(object):
def wal_name(self) -> str:
return 'wal' if self._major_version >= 100000 else 'xlog'
@property
def wal_flush(self) -> str:
"""For PostgreSQL 9.6 onwards we want to use pg_current_wal_flush_lsn()/pg_current_xlog_flush_location()."""
return '_flush' if self._major_version >= 90600 else ''
@property
def lsn_name(self) -> str:
return 'lsn' if self._major_version >= 100000 else 'location'
@@ -168,6 +186,11 @@ class Postgresql(object):
""":returns: `True` if Postgres version supports more than one synchronous node."""
return self._major_version >= 90600
@property
def can_advance_slots(self) -> bool:
"""``True`` if :attr:``major_version`` is greater than 110000."""
return self.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION
@property
def cluster_info_query(self) -> str:
"""Returns the monitoring query with a fixed number of fields.
@@ -195,15 +218,16 @@ class Postgresql(object):
"FROM pg_catalog.pg_stat_get_wal_senders() w,"
" pg_catalog.pg_stat_get_activity(w.pid)"
" WHERE w.state = 'streaming') r)").format(self.wal_name, self.lsn_name)
if (not self.global_config or self.global_config.is_synchronous_mode)
if global_config.is_synchronous_mode
and self.role in ('master', 'primary', 'promoted') else "'on', '', NULL")
if self._major_version >= 90600:
extra = ("pg_catalog.current_setting('restore_command')" if self._major_version >= 120000 else "NULL") +\
", " + ("(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, "
"plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"
" AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"
if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL") + extra
" AS confirmed_flush_lsn, pg_catalog.pg_wal_lsn_diff(restart_lsn, '0/0')::bigint"
" AS restart_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"
if self._has_permanent_slots and self.can_advance_slots else "NULL") + extra
extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END,"
" slot_name, conninfo, status, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra)
if self.role == 'standby_leader':
@@ -213,12 +237,14 @@ class Postgresql(object):
else:
extra = "0, NULL, NULL, NULL, NULL, NULL, NULL" + extra
return ("SELECT " + self.TL_LSN + ", {2}").format(self.wal_name, self.lsn_name, extra)
return ("SELECT " + self.TL_LSN + ", {3}").format(self.wal_name, self.lsn_name, self.wal_flush, extra)
@property
def available_gucs(self) -> CaseInsensitiveSet:
"""GUCs available in this Postgres server."""
return self._get_gucs()
if not self._available_gucs:
self._available_gucs = self._get_gucs()
return self._available_gucs
def _version_file_exists(self) -> bool:
return not self.data_directory_empty() and os.path.isfile(self._version_file)
@@ -246,7 +272,7 @@ class Postgresql(object):
:returns: path to Postgres binary named *cmd*.
"""
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or {}).get(cmd, cmd))
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or EMPTY_DICT).get(cmd, cmd))
def pg_ctl(self, cmd: str, *args: str, **kwargs: Any) -> bool:
"""Builds and executes pg_ctl command
@@ -272,7 +298,7 @@ class Postgresql(object):
:returns: 'ok' if PostgreSQL is up, 'reject' if starting up, 'no_resopnse' if not up."""
r = self.config.local_connect_kwargs
r = self.connection_pool.conn_kwargs
cmd = [self.pgcommand('pg_isready'), '-p', r['port'], '-d', self._database]
# Host is not set if we are connecting via default unix socket
@@ -295,11 +321,22 @@ class Postgresql(object):
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout'] / 2.0
@property
def pending_restart(self) -> bool:
return self._pending_restart
def pending_restart_reason(self) -> CaseInsensitiveDict:
"""Get :attr:`_pending_restart_reason` value.
def set_pending_restart(self, value: bool) -> None:
self._pending_restart = value
:attr:`_pending_restart_reason` is a :class:`CaseInsensitiveDict` object of the PG parameters that are
causing pending restart state. Every key is a parameter name, value - a dictionary containing the old
and the new value (see :func:`~patroni.postgresql.config.get_param_diff`).
"""
return self._pending_restart_reason
def set_pending_restart_reason(self, diff_dict: CaseInsensitiveDict) -> None:
"""Set new or update current :attr:`_pending_restart_reason`.
:param diff_dict: :class:``CaseInsensitiveDict`` object with the parameters that are causing pending restart
state with the diff of their values. Used to reset/update the :attr:`_pending_restart_reason`.
"""
self._pending_restart_reason = diff_dict
@property
def sysid(self) -> str:
@@ -323,40 +360,50 @@ class Postgresql(object):
def connection(self) -> Union['connection3', 'Connection3[Any]']:
return self._connection.get()
def set_connection_kwargs(self, kwargs: Dict[str, Any]) -> None:
self._connection.set_conn_kwargs(kwargs.copy())
self.citus_handler.set_conn_kwargs(kwargs.copy())
def _query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
"""Execute *sql* query with *params* and optionally return results.
def _query(self, sql: str, *params: Any) -> Union['Cursor[Any]', 'cursor']:
"""We are always using the same cursor, therefore this method is not thread-safe!!!
You can call it from different threads only if you are holding explicit `AsyncExecutor` lock,
because the main thread is always holding this lock when running HA cycle."""
cursor = None
:param sql: SQL statement to execute.
:param params: parameters to pass.
:returns: a query response as a list of tuples if there is any.
:raises:
:exc:`~psycopg.Error` if had issues while executing *sql*.
:exc:`~patroni.exceptions.PostgresConnectionException`: if had issues while connecting to the database.
:exc:`~patroni.utils.RetryFailedError`: if it was detected that connection/query failed due to PostgreSQL
restart.
"""
try:
cursor = self._connection.cursor()
cursor.execute(sql.encode('utf-8'), params or None)
return cursor
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, psycopg.OperationalError):
self._connection.close()
else:
raise e
return self._connection.query(sql, *params)
except PostgresConnectionException as exc:
if self.state == 'restarting':
raise RetryFailedError('cluster is being restarted')
raise PostgresConnectionException('connection problems')
raise RetryFailedError('cluster is being restarted') from exc
raise
def query(self, sql: str, *args: Any, **kwargs: Any) -> Union['Cursor[Any]', 'cursor']:
if not kwargs.get('retry', True):
return self._query(sql, *args)
def query(self, sql: str, *params: Any, retry: bool = True) -> List[Tuple[Any, ...]]:
"""Execute *sql* query with *params* and optionally return results.
:param sql: SQL statement to execute.
:param params: parameters to pass.
:param retry: whether the query should be retried upon failure or given up immediately.
:returns: a query response as a list of tuples if there is any.
:raises:
:exc:`~psycopg.Error` if had issues while executing *sql*.
:exc:`~patroni.exceptions.PostgresConnectionException`: if had issues while connecting to the database.
:exc:`~patroni.utils.RetryFailedError`: if it was detected that connection/query failed due to PostgreSQL
restart or if retry deadline was exceeded.
"""
if not retry:
return self._query(sql, *params)
try:
return self.retry(self._query, sql, *args)
except RetryFailedError as e:
raise PostgresConnectionException(str(e))
return self.retry(self._query, sql, *params)
except RetryFailedError as exc:
raise PostgresConnectionException(str(exc)) from exc
def pg_control_exists(self) -> bool:
return os.path.isfile(self._pg_control)
@@ -367,7 +414,7 @@ class Postgresql(object):
return data_directory_is_empty(self._data_dir)
def replica_method_options(self, method: str) -> Dict[str, Any]:
return deepcopy(self.config.get(method, {}) or {})
return deepcopy(self.config.get(method, {}) or EMPTY_DICT.copy())
def replica_method_can_work_without_replication_connection(self, method: str) -> bool:
return method != 'basebackup' and bool(self.replica_method_options(method).get('no_master')
@@ -393,54 +440,41 @@ class Postgresql(object):
self.config.write_postgresql_conf()
self.reload()
@property
def global_config(self) -> Optional['GlobalConfig']:
return self._global_config
def reset_cluster_info_state(self, cluster: Union[Cluster, None], nofailover: bool = False,
global_config: Optional['GlobalConfig'] = None) -> None:
def reset_cluster_info_state(self, cluster: Optional[Cluster], tags: Optional[Tags] = None) -> None:
"""Reset monitoring query cache.
It happens in the beginning of heart-beat loop and on change of `synchronous_standby_names`.
.. note::
It happens in the beginning of heart-beat loop and on change of `synchronous_standby_names`.
:param cluster: currently known cluster state from DCS
:param nofailover: whether this node could become a new primary.
Important when there are logical permanent replication slots because "nofailover"
node could do cascading replication and should enable `hot_standby_feedback`
:param global_config: last known :class:`GlobalConfig` object
:param tags: reference to an object implementing :class:`Tags` interface.
"""
self._cluster_info_state = {}
if global_config:
self._global_config = global_config
if not self._global_config:
if not tags:
return
if self._global_config.is_standby_cluster:
if global_config.is_standby_cluster:
# Standby cluster can't have logical replication slots, and we don't need to enforce hot_standby_feedback
self._has_permanent_logical_slots = False
self.set_enforce_hot_standby_feedback(False)
elif cluster and cluster.config and cluster.config.modify_version:
self._has_permanent_logical_slots =\
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
if cluster and cluster.config and cluster.config.modify_version:
# We want to enable hot_standby_feedback if the replica is supposed
# to have a logical slot or in case if it is the cascading replica.
self.set_enforce_hot_standby_feedback(
self._has_permanent_logical_slots
or cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
self.set_enforce_hot_standby_feedback(not global_config.is_standby_cluster and self.can_advance_slots
and cluster.should_enforce_hot_standby_feedback(self, tags))
self._has_permanent_slots = cluster.has_permanent_slots(self, tags)
def _cluster_info_state_get(self, name: str) -> Optional[Any]:
if not self._cluster_info_state:
try:
result = self._is_leader_retry(self._query, self.cluster_info_query).fetchone()
result = self._is_leader_retry(self._query, self.cluster_info_query)[0]
cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
'received_location', 'replay_paused', 'pg_control_timeline',
'received_tli', 'slot_name', 'conninfo', 'receiver_state',
'restore_command', 'slots', 'synchronous_commit',
'synchronous_standby_names', 'pg_stat_replication'], result))
if self._has_permanent_logical_slots:
if self._has_permanent_slots and self.can_advance_slots:
cluster_info_state['slots'] =\
self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
self._cluster_info_state = cluster_info_state
@@ -484,14 +518,14 @@ class Postgresql(object):
""":returns: a result set of 'SELECT * FROM pg_stat_replication'."""
return self._cluster_info_state_get('pg_stat_replication') or []
def replication_state_from_parameters(self, is_leader: bool, receiver_state: Optional[str],
def replication_state_from_parameters(self, is_primary: bool, receiver_state: Optional[str],
restore_command: Optional[str]) -> Optional[str]:
"""Figure out the replication state from input parameters.
.. note::
This method could be only called when Postgres is up, running and queries are successfuly executed.
:is_leader: `True` is postgres is not running in recovery
:is_primary: `True` is postgres is not running in recovery
:receiver_state: value from `pg_stat_get_wal_receiver.state` or None if Postgres is older than 9.6
:restore_command: value of ``restore_command`` GUC for PostgreSQL 12+ or
`postgresql.recovery_conf.restore_command` if it is set in Patroni configuration
@@ -500,7 +534,7 @@ class Postgresql(object):
- 'streaming' if replica is streaming according to the `pg_stat_wal_receiver` view;
- 'in archive recovery' if replica isn't streaming and there is a `restore_command`
"""
if self._major_version >= 90600 and not is_leader:
if self._major_version >= 90600 and not is_primary:
if receiver_state == 'streaming':
return 'streaming'
# For Postgres older than 12 we get `restore_command` from Patroni config, otherwise we check GUC
@@ -515,11 +549,11 @@ class Postgresql(object):
:returns: ``streaming``, ``in archive recovery``, or ``None``
"""
return self.replication_state_from_parameters(self.is_leader(),
return self.replication_state_from_parameters(self.is_primary(),
self._cluster_info_state_get('receiver_state'),
self._cluster_info_state_get('restore_command'))
def is_leader(self) -> bool:
def is_primary(self) -> bool:
try:
return bool(self._cluster_info_state_get('timeline'))
except PostgresConnectionException:
@@ -555,14 +589,17 @@ class Postgresql(object):
return match.group(1), match.group(2), match.group(3), match.group(4)
return None, None, None, None
def latest_checkpoint_location(self) -> Optional[int]:
"""Returns checkpoint location for the cleanly shut down primary.
But, if we know that the checkpoint was written to the new WAL
due to the archive_mode=on, we will return the LSN of prev wal record (SWITCH)."""
def _checkpoint_locations_from_controldata(self, data: Dict[str, str]) -> Optional[Tuple[int, int]]:
"""Get shutdown checkpoint location.
data = self.controldata()
:param data: :class:`dict` object with values returned by `pg_controldata` tool.
:returns: a tuple of checkpoint LSN for the cleanly shut down primary, and LSN of prev wal record (SWITCH)
if we know that the checkpoint was written to the new WAL file due to the archive_mode=on.
"""
timeline = data.get("Latest checkpoint's TimeLineID")
lsn = checkpoint_lsn = data.get('Latest checkpoint location')
prev_lsn = None
if data.get('Database cluster state') == 'shut down' and lsn and timeline and checkpoint_lsn:
try:
checkpoint_lsn = parse_lsn(checkpoint_lsn)
@@ -573,13 +610,26 @@ class Postgresql(object):
_, lsn, _, desc = self.parse_wal_record(timeline, prev)
prev = parse_lsn(prev)
# If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint.
# In this case we want to take the LSN of previous record (switch) as the last known WAL location.
# In this case we want to take the LSN of previous record (SWITCH) as the last known WAL location.
if lsn and parse_lsn(lsn) == prev and str(desc).strip() in ('xlog switch', 'SWITCH'):
return prev
prev_lsn = prev
except Exception as e:
logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e)
if isinstance(checkpoint_lsn, int):
return checkpoint_lsn
return checkpoint_lsn, (prev_lsn or checkpoint_lsn)
def latest_checkpoint_location(self) -> Optional[int]:
"""Get shutdown checkpoint location.
.. note::
In case if checkpoint was written to the new WAL file due to the archive_mode=on
we return LSN of the previous wal record (SWITCH).
:returns: checkpoint LSN for the cleanly shut down primary.
"""
checkpoint_locations = self._checkpoint_locations_from_controldata(self.controldata())
if checkpoint_locations:
return checkpoint_locations[1]
def is_running(self) -> Optional[PostmasterProcess]:
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
@@ -675,7 +725,7 @@ class Postgresql(object):
# the former node, otherwise, we might get a stalled one
# after kill -9, which would report incorrect data to
# patroni.
self._connection.close()
self.connection_pool.close()
if self.is_running():
logger.error('Cannot start PostgreSQL because one is already running.')
@@ -688,7 +738,7 @@ class Postgresql(object):
self.set_role(role or self.get_postgres_role_from_data_directory())
self.set_state('starting')
self._pending_restart = False
self.set_pending_restart_reason(CaseInsensitiveDict())
try:
if not self.ensure_major_version_is_known():
@@ -746,7 +796,7 @@ class Postgresql(object):
def checkpoint(self, connect_kwargs: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None) -> Optional[str]:
check_not_is_in_recovery = connect_kwargs is not None
connect_kwargs = connect_kwargs or self.config.local_connect_kwargs
connect_kwargs = connect_kwargs or self.connection_pool.conn_kwargs
for p in ['connect_timeout', 'options']:
connect_kwargs.pop(p, None)
if timeout:
@@ -765,7 +815,7 @@ class Postgresql(object):
return 'not accessible or not healty'
def stop(self, mode: str = 'fast', block_callbacks: bool = False, checkpoint: Optional[bool] = None,
on_safepoint: Optional[Callable[..., Any]] = None, on_shutdown: Optional[Callable[[int], Any]] = None,
on_safepoint: Optional[Callable[..., Any]] = None, on_shutdown: Optional[Callable[[int, int], Any]] = None,
before_shutdown: Optional[Callable[..., Any]] = None, stop_timeout: Optional[int] = None) -> bool:
"""Stop PostgreSQL
@@ -795,7 +845,7 @@ class Postgresql(object):
return success
def _do_stop(self, mode: str, block_callbacks: bool, checkpoint: bool,
on_safepoint: Optional[Callable[..., Any]], on_shutdown: Optional[Callable[..., Any]],
on_safepoint: Optional[Callable[..., Any]], on_shutdown: Optional[Callable[[int, int], Any]],
before_shutdown: Optional[Callable[..., Any]], stop_timeout: Optional[int]) -> Tuple[bool, bool]:
postmaster = self.is_running()
if not postmaster:
@@ -835,7 +885,9 @@ class Postgresql(object):
while postmaster.is_running():
data = self.controldata()
if data.get('Database cluster state', '') == 'shut down':
on_shutdown(self.latest_checkpoint_location())
checkpoint_locations = self._checkpoint_locations_from_controldata(data)
if checkpoint_locations:
on_shutdown(*checkpoint_locations)
break
elif data.get('Database cluster state', '').startswith('shut down'): # shut down in recovery
break
@@ -876,11 +928,10 @@ class Postgresql(object):
def _wait_for_connection_close(self, postmaster: PostmasterProcess) -> None:
try:
with self.connection().cursor() as cur:
while postmaster.is_running(): # Need a timeout here?
cur.execute("SELECT 1")
time.sleep(STOP_POLLING_INTERVAL)
except psycopg.Error:
while postmaster.is_running(): # Need a timeout here?
self._connection.query("SELECT 1")
time.sleep(STOP_POLLING_INTERVAL)
except (psycopg.Error, PostgresConnectionException):
pass
def reload(self, block_callbacks: bool = False) -> bool:
@@ -1157,7 +1208,7 @@ class Postgresql(object):
before_promote()
self.slots_handler.on_promote()
self.citus_handler.schedule_cache_rebuild()
self.mpp_handler.schedule_cache_rebuild()
ret = self.pg_ctl('promote', '-W')
if ret:
@@ -1167,9 +1218,9 @@ class Postgresql(object):
return ret
@staticmethod
def _wal_position(is_leader: bool, wal_position: int,
def _wal_position(is_primary: bool, wal_position: int,
received_location: Optional[int], replayed_location: Optional[int]) -> int:
return wal_position if is_leader else max(received_location or 0, replayed_location or 0)
return wal_position if is_primary else max(received_location or 0, replayed_location or 0)
def timeline_wal_position(self) -> Tuple[int, int, Optional[int]]:
# This method could be called from different threads (simultaneously with some other `_query` calls).
@@ -1181,31 +1232,21 @@ class Postgresql(object):
received_location = self.received_location()
pg_control_timeline = self._cluster_info_state_get('pg_control_timeline')
else:
with self.connection().cursor() as cursor:
cursor.execute(self.cluster_info_query.encode('utf-8'))
row = cursor.fetchone()
if TYPE_CHECKING: # pragma: no cover
assert row is not None
(timeline, wal_position, replayed_location, received_location, _, pg_control_timeline) = row[:6]
timeline, wal_position, replayed_location, received_location, _, pg_control_timeline = \
self._query(self.cluster_info_query)[0][:6]
wal_position = self._wal_position(bool(timeline), wal_position, received_location, replayed_location)
return (timeline, wal_position, pg_control_timeline)
return timeline, wal_position, pg_control_timeline
def postmaster_start_time(self) -> Optional[str]:
try:
query = "SELECT " + self.POSTMASTER_START_TIME
if current_thread().ident == self.__thread_ident:
row = self.query(query).fetchone()
else:
with self.connection().cursor() as cursor:
cursor.execute(query)
row = cursor.fetchone()
return row[0].isoformat(sep=' ') if row else None
sql = "SELECT " + self.POSTMASTER_START_TIME
return self.query(sql, retry=current_thread().ident == self.__thread_ident)[0][0].isoformat(sep=' ')
except psycopg.Error:
return None
def last_operation(self) -> int:
return self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position') or 0,
return self._wal_position(self.is_primary(), self._cluster_info_state_get('wal_position') or 0,
self.received_location(), self.replayed_location())
def configure_server_parameters(self) -> None:
@@ -1314,7 +1355,7 @@ class Postgresql(object):
"""
self.ensure_major_version_is_known()
self.slots_handler.schedule()
self.citus_handler.schedule_cache_rebuild()
self.mpp_handler.schedule_cache_rebuild()
self._sysid = ''
def _get_gucs(self) -> CaseInsensitiveSet:
@@ -0,0 +1,59 @@
import logging
import sys
from typing import Iterator
logger = logging.getLogger(__name__)
if sys.version_info < (3, 9): # pragma: no cover
from pathlib import Path
PathLikeObj = Path
conf_dir = Path(__file__).parent
else:
from importlib.resources import files
if sys.version_info < (3, 11): # pragma: no cover
from importlib.abc import Traversable
else: # pragma: no cover
from importlib.resources.abc import Traversable
PathLikeObj = Traversable
conf_dir = files(__name__)
def get_validator_files() -> Iterator[PathLikeObj]:
"""Recursively find YAML files from the current package directory.
:returns: an iterator of :class:`PathLikeObj` objects representing validator files.
"""
return _traversable_walk(conf_dir.iterdir())
def _traversable_walk(tvbs: Iterator[PathLikeObj]) -> Iterator[PathLikeObj]:
"""Recursively walk through Path/Traversable objects, yielding all YAML files in deterministic order.
:param tvbs: An iterator over :class:`PathLikeObj` objects, where each object is a file or directory
that potentially contains YAML files.
:yields: :class:`PathLikeObj` objects representing YAML files found during the traversal.
"""
for tvb in _filter_and_sort_files(tvbs):
if tvb.is_file():
yield tvb
elif tvb.is_dir():
yield from _traversable_walk(tvb.iterdir())
def _filter_and_sort_files(files: Iterator[PathLikeObj]) -> Iterator[PathLikeObj]:
"""Sort files by name, and filter out non-YAML files and Python files.
:param files: A list of files and/or directories to be filtered and sorted.
:yields: filtered and sorted objects.
"""
for file in sorted(files, key=lambda x: x.name):
if file.name.lower().endswith((".yml", ".yaml")) or file.is_dir():
yield file
elif not file.name.lower().endswith((".py", ".pyc")):
logger.info("Ignored a non-YAML file found under `%s` directory: `%s`.", __name__.split('.')[-1], file)
+50 -8
View File
@@ -7,6 +7,7 @@ import time
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember
from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare, unquote
@@ -100,10 +101,11 @@ class Bootstrap(object):
user_options.append('--{0}'.format(opt))
elif isinstance(opt, dict):
keys = list(opt.keys())
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]):
if len(keys) == 1 and isinstance(opt[keys[0]], str) and option_is_allowed(keys[0]):
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else:
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
' and value should be a string'.format(tool, opt[keys[0]]))
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else:
error_handler('Error when parsing {0} option {1}: value should be string value'
' or a single key-value pair'.format(tool, opt))
@@ -145,15 +147,52 @@ class Bootstrap(object):
# make sure there is no trigger file or postgres will be automatically promoted
trigger_file = self._postgresql.config.triggerfile_good_name
trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote'
trigger_file = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get(trigger_file) or 'promote'
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file):
os.unlink(trigger_file)
def _custom_bootstrap(self, config: Any) -> bool:
"""Bootstrap a fresh Patroni cluster using a custom method provided by the user.
:param config: configuration used for running a custom bootstrap method. It comes from the Patroni YAML file,
so it is expected to be a :class:`dict`.
.. note::
*config* must contain a ``command`` key, which value is the command or script to perform the custom
bootstrap procedure. The exit code of the ``command`` dictates if the bootstrap succeeded or failed.
When calling ``command``, Patroni will pass the following arguments to the ``command`` call:
* ``--scope``: contains the value of ``scope`` configuration;
* ``--data_dir``: contains the value of the ``postgresql.data_dir`` configuration.
You can avoid that behavior by filling the optional key ``no_params`` with the value ``False`` in the
configuration file, which will instruct Patroni to not pass these parameters to the ``command`` call.
Besides that, a couple more keys are supported in *config*, but optional:
* ``keep_existing_recovery_conf``: if ``True``, instruct Patroni to not remove the existing
``recovery.conf`` (PostgreSQL <= 11), to not discard recovery parameters from the configuration
(PostgreSQL >= 12), and to not remove the files ``recovery.signal`` or ``standby.signal``
(PostgreSQL >= 12). This is specially useful when you are restoring backups through tools like
pgBackRest and Barman, in which case they generated the appropriate recovery settings for you;
* ``recovery_conf``: a section containing a map, where each key is the name of a recovery related
setting, and the value is the value of the corresponding setting.
Any key/value other than the ones that were described above will be interpreted as additional arguments for
the ``command`` call. They will all be added to the call in the format ``--key=value``.
:returns: ``True`` if the bootstrap was successful, i.e. the execution of the custom ``command`` from *config*
exited with code ``0``, ``False`` otherwise.
"""
self._postgresql.set_state('running custom bootstrap script')
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
'--datadir=' + self._postgresql.data_dir]
# Add custom parameters specified by the user
reserved_args = {'command', 'no_params', 'keep_existing_recovery_conf', 'recovery_conf', 'scope', 'datadir'}
params += [f"--{arg}={val}" for arg, val in config.items() if arg not in reserved_args]
try:
logger.info('Running custom bootstrap script: %s', config['command'])
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
@@ -176,7 +215,7 @@ class Bootstrap(object):
"""
cmd = config.get('post_bootstrap') or config.get('post_init')
if cmd:
r = self._postgresql.config.local_connect_kwargs
r = self._postgresql.connection_pool.conn_kwargs
connstring = self._postgresql.config.format_dsn(r, True)
if 'host' not in r:
# https://www.postgresql.org/docs/current/static/libpq-pgpass.html
@@ -400,7 +439,10 @@ BEGIN
END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.query(sql)
for name, value in (config.get('users') or {}).items():
if config.get('users'):
logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0')
for name, value in (config.get('users') or EMPTY_DICT).items():
if all(name != a.get('username') for a in (superuser, replication, rewind)):
self.create_or_update_role(name, value.get('password'), value.get('options', []))
@@ -423,15 +465,15 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.restart()
else:
postgresql.config.replace_pg_hba()
if postgresql.pending_restart:
if postgresql.pending_restart_reason:
postgresql.restart()
else:
postgresql.reload()
time.sleep(1) # give a time to postgres to "reload" configuration files
postgresql.connection().close() # close connection to reconnect with a new password
else: # initdb
# We may want create database and extension for citus
self._postgresql.citus_handler.bootstrap()
# We may want create database and extension for some MPP clusters
self._postgresql.mpp_handler.bootstrap()
except Exception:
logger.exception('post_bootstrap')
task.complete(False)
+7 -2
View File
@@ -1,8 +1,9 @@
import logging
import sys
from enum import Enum
from threading import Condition, Thread
from typing import List
from typing import Any, Dict, List
from .cancellable import CancellableExecutor, CancellableSubprocess
@@ -30,7 +31,9 @@ class OnReloadExecutor(CancellableSubprocess):
self.cancel(kill=True)
self._kill_children()
with self._lock:
self._start_process(cmd, close_fds=True)
started = self._start_process(cmd, close_fds=True)
if started and self._process is not None:
Thread(target=self._process.wait).start()
class CallbackExecutor(CancellableExecutor, Thread):
@@ -51,6 +54,8 @@ class CallbackExecutor(CancellableExecutor, Thread):
If it couldn't be killed we wait until it finishes.
:param cmd: command to be executed"""
kwargs: Dict[str, Any] = {'stacklevel': 3} if sys.version_info >= (3, 8) else {}
logger.debug('CallbackExecutor.call(%s)', cmd, **kwargs)
if cmd[-3] == CallbackAction.ON_RELOAD:
return self._on_reload_executor.call_nowait(cmd)
+2 -1
View File
@@ -100,7 +100,8 @@ class CancellableSubprocess(CancellableExecutor):
if started and self._process is not None:
if isinstance(communicate, dict):
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data)
communicate['stdout'], communicate['stderr'] = \
self._process.communicate(input_data) # pyright: ignore [reportGeneralTypeIssues]
return self._process.wait()
finally:
with self._lock:
-421
View File
@@ -1,421 +0,0 @@
import logging
import re
import time
from threading import Condition, Event, Thread
from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from .connection import Connection
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
from ..psycopg import connect, quote_ident
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
from psycopg2 import cursor
from . import Postgresql
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
logger = logging.getLogger(__name__)
class PgDistNode(object):
"""Represents a single row in the `pg_dist_node` table"""
def __init__(self, group: int, host: str, port: int, event: str, nodeid: Optional[int] = None,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
self.group = group
# A weird way of pausing client connections by adding the `-demoted` suffix to the hostname
self.host = host + ('-demoted' if event == 'before_demote' else '')
self.port = port
# Event that is trying to change or changed the given row.
# Possible values: before_demote, before_promote, after_promote.
self.event = event
self.nodeid = nodeid
# If transaction was started, we need to COMMIT/ROLLBACK before the deadline
self.timeout = timeout
self.cooldown = cooldown or 10000 # 10s by default
self.deadline: float = 0
# All changes in the pg_dist_node are serialized on the Patroni
# side by performing them from a thread. The thread, that is
# requested a change, sometimes needs to wait for a result.
# For example, we want to pause client connections before demoting
# the worker, and once it is done notify the calling thread.
self._event = Event()
def wait(self) -> None:
self._event.wait()
def wakeup(self) -> None:
self._event.set()
def __eq__(self, other: Any) -> bool:
return isinstance(other, PgDistNode) and self.event == other.event\
and self.host == other.host and self.port == other.port
def __ne__(self, other: Any) -> bool:
return not self == other
def __str__(self) -> str:
return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})'
.format(self.nodeid, self.group, self.host, self.port, self.event))
def __repr__(self) -> str:
return str(self)
class CitusHandler(Thread):
def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
super(CitusHandler, self).__init__()
self.daemon = True
self._postgresql = postgresql
self._config = config
self._connection = Connection()
self._pg_dist_node: Dict[int, PgDistNode] = {} # Cache of pg_dist_node: {groupid: PgDistNode()}
self._tasks: List[PgDistNode] = [] # Requests to change pg_dist_node, every task is a `PgDistNode`
self._in_flight: Optional[PgDistNode] = None # Reference to the `PgDistNode` being changed in a transaction
self._schedule_load_pg_dist_node = True # Flag that "pg_dist_node" should be queried from the database
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
self.schedule_cache_rebuild()
def is_enabled(self) -> bool:
return isinstance(self._config, dict)
def group(self) -> Optional[int]:
return int(self._config['group']) if isinstance(self._config, dict) else None
def is_coordinator(self) -> bool:
return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID
def is_worker(self) -> bool:
return self.is_enabled() and not self.is_coordinator()
def set_conn_kwargs(self, kwargs: Dict[str, Any]) -> None:
if isinstance(self._config, dict): # self.is_enabled():
kwargs.update({'dbname': self._config['database'],
'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
self._connection.set_conn_kwargs(kwargs)
def schedule_cache_rebuild(self) -> None:
with self._condition:
self._schedule_load_pg_dist_node = True
def on_demote(self) -> None:
with self._condition:
self._pg_dist_node.clear()
self._tasks[:] = []
self._in_flight = None
def query(self, sql: str, *params: Any) -> Union['Cursor[Any]', 'cursor']:
try:
logger.debug('query(%s, %s)', sql, params)
cursor = self._connection.cursor()
cursor.execute(sql.encode('utf-8'), params or None)
return cursor
except Exception as e:
logger.error('Exception when executing query "%s", (%s): %r', sql, params, e)
self._connection.close()
with self._condition:
self._in_flight = None
self.schedule_cache_rebuild()
raise e
def load_pg_dist_node(self) -> bool:
"""Read from the `pg_dist_node` table and put it into the local cache"""
with self._condition:
if not self._schedule_load_pg_dist_node:
return True
self._schedule_load_pg_dist_node = False
try:
cursor = self.query("SELECT nodeid, groupid, nodename, nodeport, noderole"
" FROM pg_catalog.pg_dist_node WHERE noderole = 'primary'")
except Exception:
return False
with self._condition:
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in cursor}
return True
def sync_pg_dist_node(self, cluster: Cluster) -> None:
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop.
We can't always rely on REST API calls from worker nodes in order
to maintain `pg_dist_node`, therefore at least once per heartbeat
loop we make sure that workes registered in `self._pg_dist_node`
cache are matching the cluster view from DCS by creating tasks
the same way as it is done from the REST API."""
if not self.is_coordinator():
return
with self._condition:
if not self.is_alive():
self.start()
self.add_task('after_promote', CITUS_COORDINATOR_GROUP_ID, self._postgresql.connection_string)
for group, worker in cluster.workers.items():
leader = worker.leader
if leader and leader.conn_url\
and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running':
self.add_task('after_promote', group, leader.conn_url)
def find_task_by_group(self, group: int) -> Optional[int]:
for i, task in enumerate(self._tasks):
if task.group == group:
return i
def pick_task(self) -> Tuple[Optional[int], Optional[PgDistNode]]:
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
Tasks are picked by following priorities:
1. If there is already a transaction in progress, pick a task
that that will change already affected worker primary.
2. If the coordinator address should be changed - pick a task
with group=0 (coordinators are always in group 0).
3. Pick a task that is the oldest (first from the self._tasks)
"""
with self._condition:
if self._in_flight:
i = self.find_task_by_group(self._in_flight.group)
else:
while True:
i = self.find_task_by_group(CITUS_COORDINATOR_GROUP_ID) # set_coordinator
if i is None and self._tasks:
i = 0
if i is None:
break
task = self._tasks[i]
if task == self._pg_dist_node.get(task.group):
self._tasks.pop(i) # nothing to do because cached version of pg_dist_node already matches
else:
break
task = self._tasks[i] if i is not None else None
# When tasks are added it could happen that self._pg_dist_node
# wasn't ready (self._schedule_load_pg_dist_node is False)
# and hence the nodeid wasn't filled.
if task and task.group in self._pg_dist_node:
task.nodeid = self._pg_dist_node[task.group].nodeid
return i, task
def update_node(self, task: PgDistNode) -> None:
if task.nodeid is not None:
self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)',
task.nodeid, task.host, task.port, task.cooldown)
elif task.event != 'before_demote':
row = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')",
task.host, task.port, task.group).fetchone()
if row is not None:
task.nodeid = row[0]
def process_task(self, task: PgDistNode) -> bool:
"""Updates a single row in `pg_dist_node` table, optionally in a transaction.
The transaction is started if we do a demote of the worker node or before promoting the other worker if
there is no transaction in progress. And, the transaction is committed when the switchover/failover completed.
.. note:
The maximum lifetime of the transaction in progress is controlled outside of this method.
.. note:
Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread.
:param task: reference to a :class:`PgDistNode` object that represents a row to be updated/created.
:returns: `True` if the row was succesfully created/updated or transaction in progress
was committed as an indicator that the `self._pg_dist_node` cache should be updated,
or, if the new transaction was opened, this method returns `False`.
"""
if task.event == 'after_promote':
# The after_promote may happen without previous before_demote and/or
# before_promore. In this case we just call self.update_node() method.
# If there is a transaction in progress, it could be that it already did
# required changes and we can simply COMMIT.
if not self._in_flight or self._in_flight.host != task.host or self._in_flight.port != task.port:
self.update_node(task)
if self._in_flight:
self.query('COMMIT')
return True
else: # before_demote, before_promote
if task.timeout:
task.deadline = time.time() + task.timeout
if not self._in_flight:
self.query('BEGIN')
self.update_node(task)
return False
def process_tasks(self) -> None:
while True:
# Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread.
if not self._in_flight and not self.load_pg_dist_node():
break
i, task = self.pick_task()
if not task or i is None:
break
try:
update_cache = self.process_task(task)
except Exception as e:
logger.error('Exception when working with pg_dist_node: %r', e)
update_cache = None
with self._condition:
if self._tasks:
if update_cache:
self._pg_dist_node[task.group] = task
if update_cache is False: # an indicator that process_tasks has started a transaction
self._in_flight = task
else:
self._in_flight = None
if id(self._tasks[i]) == id(task):
self._tasks.pop(i)
task.wakeup()
def run(self) -> None:
while True:
try:
with self._condition:
if self._schedule_load_pg_dist_node:
timeout = -1
elif self._in_flight:
timeout = self._in_flight.deadline - time.time() if self._tasks else None
else:
timeout = -1 if self._tasks else None
if timeout is None or timeout > 0:
self._condition.wait(timeout)
elif self._in_flight:
logger.warning('Rolling back transaction. Last known status: %s', self._in_flight)
self.query('ROLLBACK')
self._in_flight = None
self.process_tasks()
except Exception:
logger.exception('run')
def _add_task(self, task: PgDistNode) -> bool:
with self._condition:
i = self.find_task_by_group(task.group)
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
if task.timeout is None:
# We don't want to override the already existing task created from REST API.
if i is not None and self._tasks[i].timeout is not None:
return False
# There is a little race condition with tasks created from REST API - the call made "before" the member
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
# task based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_pg_dist_node()
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time():
return False
# Override already existing task for the same worker group
if i is not None:
if task != self._tasks[i]:
logger.debug('Overriding existing task: %s != %s', self._tasks[i], task)
self._tasks[i] = task
self._condition.notify()
return True
# Add the task to the list if Worker node state is different from the cached `pg_dist_node`
elif self._schedule_load_pg_dist_node or task != self._pg_dist_node.get(task.group)\
or self._in_flight and task.group == self._in_flight.group:
logger.debug('Adding the new task: %s', task)
self._tasks.append(task)
self._condition.notify()
return True
return False
def add_task(self, event: str, group: int, conn_url: str,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistNode]:
try:
r = urlparse(conn_url)
except Exception as e:
return logger.error('Failed to parse connection url %s: %r', conn_url, e)
host = r.hostname
if host:
port = r.port or 5432
task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown)
return task if self._add_task(task) else None
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
if not self.is_alive():
return
worker = cluster.workers.get(event['group'])
if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url):
return
task = self.add_task(event['type'], event['group'],
worker.leader.conn_url,
event['timeout'], event['cooldown'] * 1000)
if task and event['type'] == 'before_demote':
task.wait()
def bootstrap(self) -> None:
if not isinstance(self._config, dict): # self.is_enabled()
return
conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs['options'] = '-c synchronous_commit=local -c statement_timeout=0'
if self._config['database'] != self._postgresql.database:
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
finally:
conn.close()
conn_kwargs['dbname'] = self._config['database']
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE EXTENSION citus')
superuser = self._postgresql.config.superuser
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
if params:
cur.execute("INSERT INTO pg_catalog.pg_dist_authinfo VALUES"
"(0, pg_catalog.current_user(), %s)",
(self._postgresql.config.format_dsn(params),))
if self.is_coordinator():
r = urlparse(self._postgresql.connection_string)
cur.execute("SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default')",
(r.hostname, r.port or 5432))
finally:
conn.close()
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
if not self.is_enabled():
return
# citus extension must be on the first place in shared_preload_libraries
shared_preload_libraries = list(filter(
lambda el: el and el != 'citus',
[p.strip() for p in parameters.get('shared_preload_libraries', '').split(',')]))
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
if parameters['max_prepared_transactions'] == 0:
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
# Resharding in Citus implemented using logical replication
parameters['wal_level'] = 'logical'
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
if isinstance(self._config, dict) and self._postgresql.is_leader() and\
slot['type'] == 'logical' and slot['database'] == self._config['database']:
m = CITUS_SLOT_NAME_RE.match(slot['name'])
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
return False
+147 -56
View File
@@ -9,14 +9,16 @@ import time
from contextlib import contextmanager
from urllib.parse import urlparse, parse_qsl, unquote
from types import TracebackType
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet, EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException, PostgresConnectionException
from ..file_perm import pg_perm
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
from ..utils import (compare_values, maybe_convert_from_base_unit, parse_bool, parse_int,
split_host_port, uri, validate_directory, is_subpath)
from ..validator import IntValidator, EnumValidator
if TYPE_CHECKING: # pragma: no cover
@@ -244,9 +246,10 @@ class ConfigWriter(object):
self._fd.write(line)
self._fd.write('\n')
def writelines(self, lines: List[str]) -> None:
def writelines(self, lines: List[Optional[str]]) -> None:
for line in lines:
self.writeline(line)
if isinstance(line, str):
self.writeline(line)
@staticmethod
def escape(value: Any) -> str: # Escape (by doubling) any single quotes or backslashes in given string
@@ -268,6 +271,29 @@ def _bool_is_true_validator(value: Any) -> bool:
return parse_bool(value) is True
def get_param_diff(old_value: Any, new_value: Any,
vartype: Optional[str] = None, unit: Optional[str] = None) -> Dict[str, str]:
"""Get a dictionary representing a single PG parameter's value diff.
:param old_value: current :class:`str` parameter value.
:param new_value: :class:`str` value of the paramater after a restart.
:param vartype: the target type to parse old/new_value. See ``vartype`` argument of
:func:`~patroni.utils.maybe_convert_from_base_unit`.
:param unit: unit of *old/new_value*. See ``base_unit`` argument of
:func:`~patroni.utils.maybe_convert_from_base_unit`.
:returns: a :class:`dict` object that contains two keys: ``old_value`` and ``new_value``
with their values casted to :class:`str` and converted from base units (if possible).
"""
str_value: Callable[[Any], str] = lambda x: '' if x is None else str(x)
return {
'old_value': (maybe_convert_from_base_unit(str_value(old_value), vartype, unit)
if vartype else str_value(old_value)),
'new_value': (maybe_convert_from_base_unit(str_value(new_value), vartype, unit)
if vartype else str_value(new_value))
}
class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options
@@ -326,14 +352,34 @@ class ConfigHandler(object):
.format(self._pgpass))
self._passfile = None
self._passfile_mtime = None
self._synchronous_standby_names = None
self._postmaster_ctime = None
self._current_recovery_params: Optional[CaseInsensitiveDict] = None
self._config = {}
self._recovery_params = CaseInsensitiveDict()
self._server_parameters: CaseInsensitiveDict
self._server_parameters: CaseInsensitiveDict = CaseInsensitiveDict()
self.reload_config(config)
def load_current_server_parameters(self) -> None:
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator]
keep_values = {k: self._server_parameters[k] for k in exclude}
server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
recovery_params = CaseInsensitiveDict({k: server_parameters.pop(k) for k in self._RECOVERY_PARAMETERS
if k in server_parameters})
# We also want to load current settings of recovery parameters, including primary_conninfo
# and primary_slot_name, otherwise patronictl restart will update postgresql.conf
# and remove them, what in the worst case will cause another restart.
# We are doing it only for PostgresSQL v12 onwards, because older version still have recovery.conf
if not self._postgresql.is_primary() and self._postgresql.major_version >= 120000:
# primary_conninfo is expected to be a dict, therefore we need to parse it
recovery_params['primary_conninfo'] = parse_dsn(recovery_params.pop('primary_conninfo', '')) or {}
self._recovery_params = recovery_params
self._server_parameters = CaseInsensitiveDict({**server_parameters, **keep_values})
def setup_server_parameters(self) -> None:
self._server_parameters = self.get_server_parameters(self._config)
self._adjust_recovery_parameters()
@@ -573,7 +619,8 @@ class ConfigHandler(object):
fd.write_param(name, value)
def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict:
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or {}).items()
default: Dict[str, Any] = {}
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or default).items()
if not p.lower().startswith('recovery_target')
and p.lower() not in ('primary_conninfo', 'primary_slot_name')})
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
@@ -586,15 +633,14 @@ class ConfigHandler(object):
is_remote_member = isinstance(member, RemoteMember)
primary_conninfo = self.primary_conninfo_params(member)
if primary_conninfo:
use_slots = self.get('use_slots', True) and self._postgresql.major_version >= 90400
use_slots = global_config.use_slots and self._postgresql.major_version >= 90400
if use_slots and not (is_remote_member and member.no_replication_slot):
primary_slot_name = member.primary_slot_name if is_remote_member 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_member and 'target_sesions_attrs' not in primary_conninfo and\
self._postgresql.major_version >= 100000:
if is_remote_member and ',' in primary_conninfo['host'] and self._postgresql.major_version >= 100000:
primary_conninfo['target_session_attrs'] = 'read-write'
recovery_params['primary_conninfo'] = primary_conninfo
@@ -800,7 +846,7 @@ class ConfigHandler(object):
required['restart' if mtype else 'reload'] += 1
wanted_recovery_params = self.build_recovery_params(member)
for param, value in (self._current_recovery_params or {}).items():
for param, value in (self._current_recovery_params or EMPTY_DICT).items():
# Skip certain parameters defined in the included postgres config files
# if we know that they are not specified in the patroni configuration.
if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \
@@ -921,15 +967,16 @@ class ConfigHandler(object):
parameters = config['parameters'].copy()
listen_addresses, port = split_host_port(config['listen'], 5432)
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode:
if self._synchronous_standby_names is None:
if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\
if global_config.is_synchronous_mode:
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
if synchronous_standby_names is None:
if global_config.is_synchronous_mode_strict\
and self._postgresql.role in ('master', 'primary', 'promoted'):
parameters['synchronous_standby_names'] = '*'
else:
parameters.pop('synchronous_standby_names', None)
else:
parameters['synchronous_standby_names'] = self._synchronous_standby_names
parameters['synchronous_standby_names'] = synchronous_standby_names
# Handle hot_standby <-> replica rename
if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'):
@@ -945,7 +992,7 @@ class ConfigHandler(object):
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 or 0) + 8) / 16))
self._postgresql.citus_handler.adjust_postgres_gucs(parameters)
self._postgresql.mpp_handler.adjust_postgres_gucs(parameters)
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version
or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
@@ -968,24 +1015,32 @@ class ConfigHandler(object):
return 'localhost' # connection via localhost is preferred
return listen_addresses[0].strip() # can't use localhost, take first address from listen_addresses
@property
def local_connect_kwargs(self) -> Dict[str, Any]:
ret = self._local_address.copy()
# add all of the other connection settings that are available
ret.update(self._superuser)
# if the "username" parameter is present, it actually needs to be "user"
# for connecting to PostgreSQL
if 'username' in self._superuser:
ret['user'] = self._superuser['username']
del ret['username']
# ensure certain Patroni configurations are available
ret.update({'dbname': self._postgresql.database,
'fallback_application_name': 'Patroni',
'connect_timeout': 3,
'options': '-c statement_timeout=2000'})
return ret
def resolve_connection_addresses(self) -> None:
"""Calculates and sets local and remote connection urls and options.
This method sets:
* :attr:`Postgresql.connection_string <patroni.postgresql.Postgresql.connection_string>` attribute, which
is later written to the member key in DCS as ``conn_url``.
* :attr:`ConfigHandler.local_replication_address` attribute, which is used for replication connections to
local postgres.
* :attr:`ConnectionPool.conn_kwargs <patroni.postgresql.connection.ConnectionPool.conn_kwargs>` attribute,
which is used for superuser connections to local postgres.
.. note::
If there is a valid directory in ``postgresql.parameters.unix_socket_directories`` in the Patroni
configuration and ``postgresql.use_unix_socket`` and/or ``postgresql.use_unix_socket_repl``
are set to ``True``, we respectively use unix sockets for superuser and replication connections
to local postgres.
If there is a requirement to use unix sockets, but nothing is set in the
``postgresql.parameters.unix_socket_directories``, we omit a ``host`` in connection parameters relying
on the ability of ``libpq`` to connect via some default unix socket directory.
If unix sockets are not requested we "switch" to TCP, prefering to use ``localhost`` if it is possible
to deduce that Postgres is listening on a local interface address.
Otherwise we just used the first address specified in the ``listen_addresses`` GUC.
"""
port = self._server_parameters['port']
tcp_local_address = self._get_tcp_local_address()
netloc = self._config.get('connect_address') or tcp_local_address + ':' + port
@@ -998,12 +1053,25 @@ class ConfigHandler(object):
tcp_local_address = {'host': tcp_local_address, 'port': port}
self._local_address = unix_local_address if self._config.get('use_unix_socket') else tcp_local_address
self.local_replication_address = unix_local_address\
if self._config.get('use_unix_socket_repl') else tcp_local_address
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
self._postgresql.set_connection_kwargs(self.local_connect_kwargs)
local_address = unix_local_address if self._config.get('use_unix_socket') else tcp_local_address
local_conn_kwargs = {
**local_address,
**self._superuser,
'dbname': self._postgresql.database,
'fallback_application_name': 'Patroni',
'connect_timeout': 3,
'options': '-c statement_timeout=2000'
}
# if the "username" parameter is present, it actually needs to be "user" for connecting to PostgreSQL
if 'username' in local_conn_kwargs:
local_conn_kwargs['user'] = local_conn_kwargs.pop('username')
# "notify" connection_pool about the "new" local connection address
self._postgresql.connection_pool.conn_kwargs = local_conn_kwargs
def _get_pg_settings(self, names: Collection[str]) -> Dict[Any, Tuple[Any, ...]]:
return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile'
@@ -1033,13 +1101,15 @@ class ConfigHandler(object):
def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
self._superuser = config['authentication'].get('superuser', {})
server_parameters = self.get_server_parameters(config)
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
conf_changed = hba_changed = ident_changed = local_connection_address_changed = False
param_diff = CaseInsensitiveDict()
if self._postgresql.state == 'running':
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
if p.lower() not in self._RECOVERY_PARAMETERS})
if p not in params_skip_changes})
changes.update({p: None for p in self._server_parameters.keys()
if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)})
if not (p in changes or p in params_skip_changes)})
if changes:
undef = []
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
@@ -1058,20 +1128,28 @@ class ConfigHandler(object):
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
conf_changed = True
if r[4] == 'postmaster':
pending_restart = True
logger.info('Changed %s from %s to %s (restart might be required)',
r[0], r[1], new_value)
param_diff[r[0]] = get_param_diff(r[1], new_value, r[3], r[2])
logger.info("Changed %s from '%s' to '%s' (restart might be required)",
r[0], param_diff[r[0]]['old_value'], new_value)
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
or r[0] in ('listen_addresses', 'port'):
local_connection_address_changed = True
else:
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
logger.info("Changed %s from '%s' to '%s'",
r[0], maybe_convert_from_base_unit(r[1], r[3], r[2]), new_value)
elif r[0] in self._server_parameters \
and not compare_values(r[3], r[2], r[1], self._server_parameters[r[0]]):
# Check if any parameter was set back to the current pg_settings value
# We can use pg_settings value here, as it is proved to be equal to new_value
logger.info("Changed %s from '%s' to '%s'", r[0], self._server_parameters[r[0]], new_value)
conf_changed = True
for param, value in changes.items():
if '.' in param:
# Check that user-defined-paramters have changed (parameters with period in name)
# Check that user-defined-parameters have changed (parameters with period in name)
if value is None or param not in self._server_parameters \
or str(value) != str(self._server_parameters[param]):
logger.info('Changed %s from %s to %s', param, self._server_parameters.get(param), value)
logger.info("Changed %s from '%s' to '%s'",
param, self._server_parameters.get(param), value)
conf_changed = True
elif param in server_parameters:
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
@@ -1086,7 +1164,6 @@ class ConfigHandler(object):
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
self._config = config
self._postgresql.set_pending_restart(pending_restart)
self._server_parameters = server_parameters
self._adjust_recovery_parameters()
self._krbsrvname = config.get('krbsrvname')
@@ -1116,25 +1193,36 @@ 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 pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in self._RECOVERY_PARAMETERS]).fetchone() or (0,))[0] > 0
self._postgresql.set_pending_restart(pending_restart)
settings_diff: CaseInsensitiveDict = CaseInsensitiveDict()
for param, value, unit, vartype in self._postgresql.query(
'SELECT name, pg_catalog.current_setting(name), unit, vartype FROM pg_catalog.pg_settings'
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in params_skip_changes]):
new_value = self._postgresql.get_guc_value(param)
new_value = '?' if new_value is None else new_value
settings_diff[param] = get_param_diff(value, new_value, vartype, unit)
external_change = {param: value for param, value in settings_diff.items()
if param not in param_diff or value != param_diff[param]}
if external_change:
logger.info("PostgreSQL configuration parameters requiring restart"
" (%s) seem to be changed bypassing Patroni config."
" Setting 'Pending restart' flag", ', '.join(external_change))
param_diff = settings_diff
except Exception as e:
logger.warning('Exception %r when running query', e)
else:
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
self._postgresql.set_pending_restart_reason(param_diff)
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
"""Updates synchronous_standby_names and reloads if necessary.
:returns: True if value was updated."""
if value != self._synchronous_standby_names:
if value != self._server_parameters.get('synchronous_standby_names'):
if value is None:
self._server_parameters.pop('synchronous_standby_names', None)
else:
self._server_parameters['synchronous_standby_names'] = value
self._synchronous_standby_names = value
if self._postgresql.state == 'running':
self.write_postgresql_conf()
self._postgresql.reload()
@@ -1169,6 +1257,7 @@ class ConfigHandler(object):
data = self._postgresql.controldata()
effective_configuration = self._server_parameters.copy()
param_diff = CaseInsensitiveDict()
for name, cname in options_mapping.items():
value = parse_int(effective_configuration[name])
if cname not in data:
@@ -1178,7 +1267,10 @@ class ConfigHandler(object):
cvalue = parse_int(data[cname])
if cvalue is not None and value is not None and cvalue > value:
effective_configuration[name] = cvalue
self._postgresql.set_pending_restart(True)
logger.info("%s value in pg_controldata: %d, in the global configuration: %d."
" pg_controldata value will be used. Setting 'Pending restart' flag", name, cvalue, value)
param_diff[name] = get_param_diff(cvalue, value)
self._postgresql.set_pending_restart_reason(param_diff)
# If we are using custom bootstrap with PITR it could fail when values like max_connections
# are increased, therefore we disable hot_standby if recovery_target_action == 'promote'.
@@ -1195,7 +1287,6 @@ class ConfigHandler(object):
if disable_hot_standby:
effective_configuration['hot_standby'] = 'off'
self._postgresql.set_pending_restart(True)
return effective_configuration
@@ -1234,4 +1325,4 @@ class ConfigHandler(object):
return self._config.get(key, default)
def restore_command(self) -> Optional[str]:
return (self.get('recovery_conf') or {}).get('restore_command')
return (self.get('recovery_conf') or EMPTY_DICT).get('restore_command')
+126 -17
View File
@@ -2,45 +2,154 @@ import logging
from contextlib import contextmanager
from threading import Lock
from typing import Any, Dict, Iterator, Union, TYPE_CHECKING
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from psycopg import Connection as Connection3, Cursor
from psycopg import Connection, Cursor
from psycopg2 import connection, cursor
from .. import psycopg
from ..exceptions import PostgresConnectionException
logger = logging.getLogger(__name__)
class Connection(object):
class NamedConnection:
"""Helper class to manage ``psycopg`` connections from Patroni to PostgreSQL.
:ivar server_version: PostgreSQL version in integer format where we are connected to.
"""
server_version: int
def __init__(self) -> None:
self._lock = Lock()
def __init__(self, pool: 'ConnectionPool', name: str, kwargs_override: Optional[Dict[str, Any]]) -> None:
"""Create an instance of :class:`NamedConnection` class.
:param pool: reference to a :class:`ConnectionPool` object.
:param name: name of the connection.
:param kwargs_override: :class:`dict` object with connection parameters that should be
different from default values provided by connection *pool*.
"""
self._pool = pool
self._name = name
self._kwargs_override = kwargs_override or {}
self._lock = Lock() # used to make sure that only one connection to postgres is established
self._connection = None
self._cursor_holder = None
def set_conn_kwargs(self, conn_kwargs: Dict[str, Any]) -> None:
self._conn_kwargs = conn_kwargs
@property
def _conn_kwargs(self) -> Dict[str, Any]:
"""Connection parameters for this :class:`NamedConnection`."""
return {**self._pool.conn_kwargs, **self._kwargs_override, 'application_name': f'Patroni {self._name}'}
def get(self) -> Union['connection', 'Connection3[Any]']:
def get(self) -> Union['connection', 'Connection[Any]']:
"""Get ``psycopg``/``psycopg2`` connection object.
.. note::
Opens a new connection if necessary.
:returns: ``psycopg`` or ``psycopg2`` connection object.
"""
with self._lock:
if not self._connection or self._connection.closed != 0:
logger.info("establishing a new patroni %s connection to postgres", self._name)
self._connection = psycopg.connect(**self._conn_kwargs)
self.server_version = getattr(self._connection, 'server_version', 0)
return self._connection
def cursor(self) -> Union['cursor', 'Cursor[Any]']:
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
logger.info("establishing a new patroni connection to the postgres cluster")
self._cursor_holder = self.get().cursor()
return self._cursor_holder
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
"""Execute a query with parameters and optionally returns a response.
def close(self) -> None:
:param sql: SQL statement to execute.
:param params: parameters to pass.
:returns: a query response as a list of tuples if there is any.
:raises:
:exc:`~psycopg.Error` if had issues while executing *sql*.
:exc:`~patroni.exceptions.PostgresConnectionException`: if had issues while connecting to the database.
"""
cursor = None
try:
with self.get().cursor() as cursor:
cursor.execute(sql.encode('utf-8'), params or None)
return cursor.fetchall() if cursor.rowcount and cursor.rowcount > 0 else []
except psycopg.Error as exc:
if cursor and cursor.connection.closed == 0:
# When connected via unix socket, psycopg2 can't recoginze 'connection lost' and leaves
# `self._connection.closed == 0`, but the generic exception is raised. It doesn't make
# sense to continue with existing connection and we will close it, to avoid its reuse.
if type(exc) in (psycopg.DatabaseError, psycopg.OperationalError):
self.close()
else:
raise exc
raise PostgresConnectionException('connection problems') from exc
def close(self, silent: bool = False) -> bool:
"""Close the psycopg connection to postgres.
:param silent: whether the method should not write logs.
:returns: ``True`` if ``psycopg`` connection was closed, ``False`` otherwise.``
"""
ret = False
if self._connection and self._connection.closed == 0:
self._connection.close()
logger.info("closed patroni connection to the postgresql cluster")
self._cursor_holder = self._connection = None
if not silent:
logger.info("closed patroni %s connection to postgres", self._name)
ret = True
self._connection = None
return ret
class ConnectionPool:
"""Helper class to manage named connections from Patroni to PostgreSQL.
The instance keeps named :class:`NamedConnection` objects and parameters that must be used for new connections.
"""
def __init__(self) -> None:
"""Create an instance of :class:`ConnectionPool` class."""
self._lock = Lock()
self._connections: Dict[str, NamedConnection] = {}
self._conn_kwargs: Dict[str, Any] = {}
@property
def conn_kwargs(self) -> Dict[str, Any]:
"""Connection parameters that must be used for new ``psycopg`` connections."""
with self._lock:
return self._conn_kwargs.copy()
@conn_kwargs.setter
def conn_kwargs(self, value: Dict[str, Any]) -> None:
"""Set new connection parameters.
:param value: :class:`dict` object with connection parameters.
"""
with self._lock:
self._conn_kwargs = value
def get(self, name: str, kwargs_override: Optional[Dict[str, Any]] = None) -> NamedConnection:
"""Get a new named :class:`NamedConnection` object from the pool.
.. note::
Creates a new :class:`NamedConnection` object if it doesn't yet exist in the pool.
:param name: name of the connection.
:param kwargs_override: :class:`dict` object with connection parameters that should be
different from default values provided by :attr:`conn_kwargs`.
:returns: :class:`NamedConnection` object.
"""
with self._lock:
if name not in self._connections:
self._connections[name] = NamedConnection(self, name, kwargs_override)
return self._connections[name]
def close(self) -> None:
"""Close all named connections from Patroni to PostgreSQL registered in the pool."""
with self._lock:
closed_connections = [conn.close(True) for conn in self._connections.values()]
if any(closed_connections):
logger.info("closed patroni connections to postgres")
@contextmanager
+317
View File
@@ -0,0 +1,317 @@
"""Abstract classes for MPP handler.
MPP stands for Massively Parallel Processing, and Citus belongs to this architecture. Currently, Citus is the only
supported MPP cluster. However, we may consider adapting other databases such as TimescaleDB, GPDB, etc. into Patroni.
"""
import abc
from typing import Any, Dict, Iterator, Optional, Union, Tuple, Type, TYPE_CHECKING
from ...dcs import Cluster
from ...dynamic_loader import iter_classes
from ...exceptions import PatroniException
if TYPE_CHECKING: # pragma: no cover
from .. import Postgresql
from ...config import Config
class AbstractMPP(abc.ABC):
"""An abstract class which should be passed to :class:`AbstractDCS`.
.. note::
We create :class:`AbstractMPP` and :class:`AbstractMPPHandler` to solve the chicken-egg initialization problem.
When initializing DCS, we dynamically create an object implementing :class:`AbstractMPP`, later this object is
used to instantiate an object implementing :class:`AbstractMPPHandler`.
"""
group_re: Any # re.Pattern[str]
def __init__(self, config: Dict[str, Union[str, int]]) -> None:
"""Init method for :class:`AbstractMPP`.
:param config: configuration of MPP section.
"""
self._config = config
def is_enabled(self) -> bool:
"""Check if MPP is enabled for a given MPP.
.. note::
We just check that the :attr:`_config` object isn't empty and expect
it to be empty only in case of :class:`Null`.
:returns: ``True`` if MPP is enabled, otherwise ``False``.
"""
return bool(self._config)
@staticmethod
@abc.abstractmethod
def validate_config(config: Any) -> bool:
"""Check whether provided config is good for a given MPP.
:param config: configuration of MPP section.
:returns: ``True`` is config passes validation, otherwise ``False``.
"""
@property
@abc.abstractmethod
def group(self) -> Any:
"""The group for a given MPP implementation."""
@property
@abc.abstractmethod
def coordinator_group_id(self) -> Any:
"""The group id of the coordinator PostgreSQL cluster."""
@property
def type(self) -> str:
"""The type of the MPP cluster.
:returns: A string representation of the type of a given MPP implementation.
"""
for base in self.__class__.__bases__:
if not base.__name__.startswith('Abstract'):
return base.__name__
return self.__class__.__name__
@property
def k8s_group_label(self):
"""Group label used for kubernetes DCS of the MPP cluster.
:returns: A string representation of the k8s group label of a given MPP implementation.
"""
return self.type.lower() + '-group'
def is_coordinator(self) -> bool:
"""Check whether this node is running in the coordinator PostgreSQL cluster.
:returns: ``True`` if MPP is enabled and the group id of this node
matches with the :attr:`coordinator_group_id`, otherwise ``False``.
"""
return self.is_enabled() and self.group == self.coordinator_group_id
def is_worker(self) -> bool:
"""Check whether this node is running as a MPP worker PostgreSQL cluster.
:returns: ``True`` if MPP is enabled and this node is known to be not running
as the coordinator PostgreSQL cluster, otherwise ``False``.
"""
return self.is_enabled() and not self.is_coordinator()
def _get_handler_cls(self) -> Iterator[Type['AbstractMPPHandler']]:
"""Find Handler classes inherited from a class type of this object.
:yields: handler classes for this object.
"""
for cls in self.__class__.__subclasses__():
if issubclass(cls, AbstractMPPHandler) and cls.__name__.startswith(self.__class__.__name__):
yield cls
def get_handler_impl(self, postgresql: 'Postgresql') -> 'AbstractMPPHandler':
"""Find and instantiate Handler implementation of this object.
:param postgresql: a reference to :class:`Postgresql` object.
:raises:
:exc:`PatroniException`: if the Handler class haven't been found.
:returns: an instantiated class that implements Handler for this object.
"""
for cls in self._get_handler_cls():
return cls(postgresql, self._config)
raise PatroniException(f'Failed to initialize {self.__class__.__name__}Handler object')
class AbstractMPPHandler(AbstractMPP):
"""An abstract class which defines interfaces that should be implemented by real handlers."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
"""Init method for :class:`AbstractMPPHandler`.
:param postgresql: a reference to :class:`Postgresql` object.
:param config: configuration of MPP section.
"""
super().__init__(config)
self._postgresql = postgresql
@abc.abstractmethod
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
"""Handle an event sent from a worker node.
:param cluster: the currently known cluster state from DCS.
:param event: the event to be handled.
"""
@abc.abstractmethod
def sync_meta_data(self, cluster: Cluster) -> None:
"""Sync meta data on the coordinator.
:param cluster: the currently known cluster state from DCS.
"""
@abc.abstractmethod
def on_demote(self) -> None:
"""On demote handler.
Is called when the primary was demoted.
"""
@abc.abstractmethod
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
Is called to notify handler that it has to refresh its metadata cache from the database.
"""
@abc.abstractmethod
def bootstrap(self) -> None:
"""Bootstrap handler.
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
"""
@abc.abstractmethod
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
"""Adjust GUCs in the current PostgreSQL configuration.
:param parameters: dictionary of GUCs, with key as GUC name and the corresponding value as current GUC value.
"""
@abc.abstractmethod
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
"""Check whether provided replication *slot* existing in the database should not be removed.
.. note::
MPP database may create replication slots for its own use, for example to migrate data between workers
using logical replication, and we don't want to suddenly drop them.
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
``plugin``.
:returns: ``True`` if the replication slots should not be removed, otherwise ``False``.
"""
class Null(AbstractMPP):
"""Dummy implementation of :class:`AbstractMPP`."""
def __init__(self) -> None:
"""Init method for :class:`Null`."""
super().__init__({})
@staticmethod
def validate_config(config: Any) -> bool:
"""Check whether provided config is good for :class:`Null`.
:returns: always ``True``.
"""
return True
@property
def group(self) -> None:
"""The group for :class:`Null`.
:returns: always ``None``.
"""
return None
@property
def coordinator_group_id(self) -> None:
"""The group id of the coordinator PostgreSQL cluster.
:returns: always ``None``.
"""
return None
class NullHandler(Null, AbstractMPPHandler):
"""Dummy implementation of :class:`AbstractMPPHandler`."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
"""Init method for :class:`NullHandler`.
:param postgresql: a reference to :class:`Postgresql` object.
:param config: configuration of MPP section.
"""
AbstractMPPHandler.__init__(self, postgresql, config)
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
"""Handle an event sent from a worker node.
:param cluster: the currently known cluster state from DCS.
:param event: the event to be handled.
"""
def sync_meta_data(self, cluster: Cluster) -> None:
"""Sync meta data on the coordinator.
:param cluster: the currently known cluster state from DCS.
"""
def on_demote(self) -> None:
"""On demote handler.
Is called when the primary was demoted.
"""
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
Is called to notify handler that it has to refresh its metadata cache from the database.
"""
def bootstrap(self) -> None:
"""Bootstrap handler.
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
"""
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
"""Adjust GUCs in the current PostgreSQL configuration.
:param parameters: dictionary of GUCs, with key as GUC name and corresponding value as current GUC value.
"""
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
"""Check whether provided replication *slot* existing in the database should not be removed.
.. note::
MPP database may create replication slots for its own use, for example to migrate data between workers
using logical replication, and we don't want to suddenly drop them.
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
``plugin``.
:returns: always ``False``.
"""
return False
def iter_mpp_classes(
config: Optional[Union['Config', Dict[str, Any]]] = None
) -> Iterator[Tuple[str, Type[AbstractMPP]]]:
"""Attempt to import MPP modules that are present in the given configuration.
:param config: configuration information with possible MPP names as keys. If given, only attempt to import MPP
modules defined in the configuration. Else, if ``None``, attempt to import any supported MPP module.
:yields: tuples, each containing the module ``name`` and the imported MPP class object.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
yield from iter_classes(__package__, AbstractMPP, config)
def get_mpp(config: Union['Config', Dict[str, Any]]) -> AbstractMPP:
"""Attempt to load and instantiate a MPP module from known available implementations.
:param config: object or dictionary with Patroni configuration.
:returns: The successfully loaded MPP or fallback to :class:`Null`.
"""
for name, mpp_class in iter_mpp_classes(config):
if mpp_class.validate_config(config[name]):
return mpp_class(config[name])
return Null()
+773
View File
@@ -0,0 +1,773 @@
import logging
import re
import time
from threading import Condition, Event, Thread
from urllib.parse import urlparse
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Set, Tuple, TYPE_CHECKING
from . import AbstractMPP, AbstractMPPHandler
from ...dcs import Cluster
from ...psycopg import connect, quote_ident, ProgrammingError
from ...utils import parse_int
if TYPE_CHECKING: # pragma: no cover
from .. import Postgresql
CITUS_COORDINATOR_GROUP_ID = 0
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
logger = logging.getLogger(__name__)
class PgDistNode:
"""Represents a single row in "pg_dist_node" table.
.. note::
Unlike "noderole" possible values of ``role`` are ``primary``, ``secondary``, and ``demoted``.
The last one is used to pause client connections on the coordinator to the worker by
appending ``-demoted`` suffix to the "nodename". The actual "noderole" in DB remains ``primary``.
:ivar host: "nodename" value
:ivar port: "nodeport" value
:ivar role: "noderole" value
:ivar nodeid: "nodeid" value
"""
def __init__(self, host: str, port: int, role: str, nodeid: Optional[int] = None) -> None:
"""Create a :class:`PgDistNode` object based on given arguments.
:param host: "nodename" of the Citus coordinator or worker.
:param port: "nodeport" of the Citus coordinator or worker.
:param role: "noderole" value.
:param nodeid: id of the row in the "pg_dist_node".
"""
self.host = host
self.port = port
self.role = role
self.nodeid = nodeid
def __hash__(self) -> int:
"""Defines a hash function to put :class:`PgDistNode` objects to :class:`PgDistGroup` set-like object.
.. note::
We use (:attr:`host`, :attr:`port`) tuple here because it is one of the UNIQUE constraints on the
"pg_dist_node" table. The :attr:`role` value is irrelevant here because nodes may change their roles.
"""
return hash((self.host, self.port))
def __eq__(self, other: Any) -> bool:
"""Defines a comparison function.
:returns: ``True`` if :attr:`host` and :attr:`port` between two instances are the same.
"""
return isinstance(other, PgDistNode) and self.host == other.host and self.port == other.port
def __str__(self) -> str:
return ('PgDistNode(nodeid={0},host={1},port={2},role={3})'
.format(self.nodeid, self.host, self.port, self.role))
def __repr__(self) -> str:
return str(self)
def is_primary(self) -> bool:
"""Checks whether this object represents "primary" in a corresponding group.
:returns: ``True`` if this object represents the ``primary``.
"""
return self.role in ('primary', 'demoted')
def as_tuple(self, include_nodeid: bool = False) -> Tuple[str, int, str, Optional[int]]:
"""Helper method to compare two :class:`PgDistGroup` objects.
.. note::
*include_nodeid* is set to ``True`` only in unit-tests.
:param include_nodeid: whether :attr:`nodeid` should be taken into account when comparison is performed.
:returns: :class:`tuple` object with :attr:`host`, :attr:`port`, :attr:`role`, and optionally :attr:`nodeid`.
"""
return self.host, self.port, self.role, (self.nodeid if include_nodeid else None)
class PgDistGroup(Set[PgDistNode]):
"""A :class:`set`-like object that represents a Citus group in "pg_dist_node" table.
This class implements a set of methods to compare topology and if it is necessary
to transition from the old to the new topology in a "safe" manner:
* register new primary/secondaries
* replace gone secondaries with added secondaries
* failover and switchover
Typically there will be at least one :class:`PgDistNode` object registered (``primary``).
In addition to that there could be one or more ``secondary`` nodes.
:ivar failover: whether the ``primary`` row should be updated as a result of :func:`transition` method call.
:ivar groupid: the "groupid" from "pg_dist_node".
"""
def __init__(self, groupid: int, nodes: Optional[Collection[PgDistNode]] = None) -> None:
"""Creates a :class:`PgDistGroup` object based on given arguments.
:param groupid: the groupid from "pg_dist_node".
:param nodes: a collection of :class:`PgDistNode` objects that belog to a *groupid*.
"""
self.failover = False
self.groupid = groupid
if nodes:
self.update(nodes)
def equals(self, other: 'PgDistGroup', check_nodeid: bool = False) -> bool:
"""Compares two :class:`PgDistGroup` objects.
:param other: what we want to compare with.
:param check_nodeid: whether :attr:`PgDistNode.nodeid` should be compared in addition to
:attr:`PgDistNode.host`, :attr:`PgDistNode.port`, and :attr:`PgDistNode.role`.
:returns: ``True`` if two :class:`PgDistGroup` objects are fully identical.
"""
return self.groupid == other.groupid\
and set(v.as_tuple(check_nodeid) for v in self) == set(v.as_tuple(check_nodeid) for v in other)
def primary(self) -> Optional[PgDistNode]:
"""Finds and returns :class:`PgDistNode` object that represents the "primary".
:returns: :class:`PgDistNode` object which represents the "primary" or ``None`` if not found.
"""
return next(iter(v for v in self if v.is_primary()), None)
def get(self, value: PgDistNode) -> Optional[PgDistNode]:
"""Performs a lookup of the actual value in a set.
.. note::
It is necessary because :func:`__hash__` and :func:`__eq__` methods in :class:`PgDistNode` are
redefined and effectively they check only :attr:`PgDistNode.host` and :attr:`PgDistNode.port` attributes.
:param value: the key we search for.
:returns: the actual :class:`PgDistNode` value from this :class:`PgDistGroup` object or ``None`` if not found.
"""
return next(iter(v for v in self if v == value), None)
def transition(self, old: 'PgDistGroup') -> Iterator[PgDistNode]:
"""Compares this topology with the old one and yields transitions that transform the old to the new one.
.. note::
The actual yielded object is :class:`PgDistNode` that will be passed to
the :meth:`CitusHandler.update_node` to execute all transitions in a transaction.
In addition to the yielding transactions this method fills up :attr:`PgDistNode.nodeid`
attribute for nodes that are presented in the old and in the new topology.
There are a few simple rules/constraints that are imposed by Citus and must be followed:
- adding/removing nodes is only possible when metadata is synced to all registered "priorities".
- the "primary" row in "pg_dist_node" always keeps the nodeid (unless it is
removed, but it is not supported by Patroni).
- "nodename", "nodeport" must be unique across all rows in the "pg_dist_node". This means that
every time we want to change the nodeid of an existing node (i.e. to change it from secondary
to primary), we should first write some other "nodename"/"nodeport" to the row it's currently in.
- updating "broken" nodes always works and metadata is synced asynchnonously after the commit.
Following these rules below is an example of the switchover between node1 (primary, nodeid=4)
and node2 (secondary, nodeid=5).
.. code-block:: SQL
BEGIN;
SELECT citus_update_node(4, 'node1-demoted', 5432);
SELECT citus_update_node(5, 'node1', 5432);
SELECT citus_update_node(4, 'node2', 5432);
COMMIT;
:param old: the last known topology registered in "pg_dist_node" for a given :attr:`groupid`.
:yields: :class:`PgDistNode` objects that must be updated/added/removed in "pg_dist_node".
"""
self.failover = old.failover
new_primary = self.primary()
assert new_primary is not None
old_primary = old.primary()
gone_nodes = old - self - {old_primary}
added_nodes = self - old - {new_primary}
if not old_primary:
# We did not have any nodes in the group yet and we're adding one now
yield new_primary
elif old_primary == new_primary:
new_primary.nodeid = old_primary.nodeid
# Controlled switchover with pausing client connections.
# Achieved by updating the primary row and putting hostname = '${host}-demoted' in a transaction.
if old_primary.role != new_primary.role:
self.failover = True
yield new_primary
elif old_primary != new_primary:
self.failover = True
new_primary_old_node = old.get(new_primary)
old_primary_new_node = self.get(old_primary)
# The new primary was registered as a secondary before failover
if new_primary_old_node:
new_node = None
# Old primary is gone and some new secondaries were added.
# We can use the row of promoted secondary to add the new secondary.
if not old_primary_new_node and added_nodes:
new_node = added_nodes.pop()
new_node.nodeid = new_primary_old_node.nodeid
yield new_node
# notify _maybe_register_old_primary_as_secondary that the old primary should not be re-registered
old_primary.role = 'secondary'
# In opposite case we need to change the primary record to '${host}-demoted:${port}'
# before we can put its host:port to the row of promoted secondary.
elif old_primary.role == 'primary':
old_primary.role = 'demoted'
yield old_primary
# The old primary is gone and the promoted secondary row wasn't yet used.
if not old_primary_new_node and not new_node:
# We have to "add" the gone primary to the row of promoted secondary because
# nodes could not be removed while the metadata isn't synced.
old_primary_new_node = PgDistNode(old_primary.host, old_primary.port, new_primary_old_node.role)
self.add(old_primary_new_node)
# put the old primary instead of promoted secondary
if old_primary_new_node:
old_primary_new_node.nodeid = new_primary_old_node.nodeid
yield old_primary_new_node
# update the primary record with the new information
new_primary.nodeid = old_primary.nodeid
yield new_primary
# The new primary was never registered as a standby and there are secondaries that have gone away. Since
# nodes can't be removed while metadata isn't synced we have to temporarily "add" the old primary back.
if not new_primary_old_node and gone_nodes:
# We were in the middle of controlled switchover while the primary disappeared.
# If there are any gone nodes that can't be reused for new secondaries we will
# use one of them to temporarily "add" the old primary back as a secondary.
if not old_primary_new_node and old_primary.role == 'demoted' and len(gone_nodes) > len(added_nodes):
old_primary_new_node = PgDistNode(old_primary.host, old_primary.port, 'secondary')
self.add(old_primary_new_node)
# Use one of the gone secondaries to put host:port of the old primary there.
if old_primary_new_node:
old_primary_new_node.nodeid = gone_nodes.pop().nodeid
yield old_primary_new_node
# Fill nodeid for standbys in the new topology from the old ones
old_replicas = {v: v for v in old if not v.is_primary()}
for n in self:
if not n.is_primary() and not n.nodeid and n in old_replicas:
n.nodeid = old_replicas[n].nodeid
# Reuse nodeid's of gone standbys to "add" new standbys
while gone_nodes and added_nodes:
a = added_nodes.pop()
a.nodeid = gone_nodes.pop().nodeid
yield a
# Adding or removing nodes operations are executed on primaries in all Citus groups in 2PC.
# If we know that the primary was updated (self.failover is True) that automatically means that
# adding/removing nodes calls will fail and the whole transaction will be aborted. Therefore
# we discard operations that add/remove secondaries if we know that the primary was just updated.
# The inconsistency will be automatically resolved on the next Patroni heartbeat loop.
# Remove remaining nodes that are gone, but only in case if metadata is in sync (self.failover is False).
for g in gone_nodes:
if not self.failover:
# Remove the node if we expect metadata to be in sync
yield PgDistNode(g.host, g.port, '')
else:
# Otherwise add these nodes to the new topology
self.add(g)
# Add new nodes to the metadata, but only in case if metadata is in sync (self.failover is False).
for a in added_nodes:
if not self.failover:
# Add the node if we expect metadata to be in sync
yield a
else:
# Otherwise remove them from the new topology
self.discard(a)
class PgDistTask(PgDistGroup):
"""A "task" that represents the current or desired state of "pg_dist_node" for a provided *groupid*.
:ivar group: the "groupid" in "pg_dist_node".
:ivar event: an "event" that resulted in creating this task.
possible values: "before_demote", "before_promote", "after_promote".
:ivar timeout: a transaction timeout if the task resulted in starting a transaction.
:ivar cooldown: the cooldown value for ``citus_update_node()`` UDF call.
:ivar deadline: the time in unix seconds when the transaction is allowed to be rolled back.
"""
def __init__(self, groupid: int, nodes: Optional[Collection[PgDistNode]], event: str,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
"""Create a :class:`PgDistTask` object based on given arguments.
:param groupid: the groupid from "pg_dist_node".
:param nodes: a collection of :class:`PgDistNode` objects that belog to a *groupid*.
:param event: an "event" that resulted in creating this task.
:param timeout: a transaction timeout if the task resulted in starting a transaction.
:param cooldown: the cooldown value for ``citus_update_node()`` UDF call.
"""
super(PgDistTask, self).__init__(groupid, nodes)
# Event that is trying to change or changed the given row.
# Possible values: before_demote, before_promote, after_promote.
self.event = event
# If transaction was started, we need to COMMIT/ROLLBACK before the deadline
self.timeout = timeout
self.cooldown = cooldown or 10000 # 10s by default
self.deadline: float = 0
# All changes in the pg_dist_node are serialized on the Patroni
# side by performing them from a thread. The thread, that is
# requested a change, sometimes needs to wait for a result.
# For example, we want to pause client connections before demoting
# the worker, and once it is done notify the calling thread.
self._event = Event()
def wait(self) -> None:
"""Wait until this task is processed by a dedicated thread."""
self._event.wait()
def wakeup(self) -> None:
"""Notify a thread that created a task that it was processed."""
self._event.set()
def __eq__(self, other: Any) -> bool:
return isinstance(other, PgDistTask) and self.event == other.event\
and super(PgDistTask, self).equals(other)
def __ne__(self, other: Any) -> bool:
return not self == other
class Citus(AbstractMPP):
group_re = re.compile('^(0|[1-9][0-9]*)$')
@staticmethod
def validate_config(config: Union[Any, Dict[str, Union[str, int]]]) -> bool:
"""Check whether provided config is good for a given MPP.
:param config: configuration of ``citus`` MPP section.
:returns: ``True`` is config passes validation, otherwise ``False``.
"""
return isinstance(config, dict) \
and isinstance(config.get('database'), str) \
and parse_int(config.get('group')) is not None
@property
def group(self) -> int:
"""The group of this Citus node."""
return int(self._config['group'])
@property
def coordinator_group_id(self) -> int:
"""The group id of the Citus coordinator PostgreSQL cluster."""
return CITUS_COORDINATOR_GROUP_ID
class CitusHandler(Citus, AbstractMPPHandler, Thread):
"""Define the interfaces for handling an underlying Citus cluster."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
""""Initialize a new instance of :class:`CitusHandler`.
:param postgresql: the Postgres node.
:param config: the ``citus`` MPP config section.
"""
Thread.__init__(self)
AbstractMPPHandler.__init__(self, postgresql, config)
self.daemon = True
if config:
self._connection = postgresql.connection_pool.get(
'citus', {'dbname': config['database'],
'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
self._pg_dist_group: Dict[int, PgDistTask] = {} # Cache of pg_dist_node: {groupid: PgDistTask()}
self._tasks: List[PgDistTask] = [] # Requests to change pg_dist_group, every task is a `PgDistTask`
self._in_flight: Optional[PgDistTask] = None # Reference to the `PgDistTask` being changed in a transaction
self._schedule_load_pg_dist_group = True # Flag that "pg_dist_group" should be queried from the database
self._condition = Condition() # protects _pg_dist_group, _tasks, _in_flight, and _schedule_load_pg_dist_group
self.schedule_cache_rebuild()
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
Is called to notify handler that it has to refresh its metadata cache from the database.
"""
with self._condition:
self._schedule_load_pg_dist_group = True
def on_demote(self) -> None:
with self._condition:
self._pg_dist_group.clear()
empty_tasks: List[PgDistTask] = []
self._tasks[:] = empty_tasks
self._in_flight = None
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
try:
logger.debug('query(%s, %s)', sql, params)
return self._connection.query(sql, *params)
except Exception as e:
logger.error('Exception when executing query "%s", (%s): %r', sql, params, e)
self._connection.close()
with self._condition:
self._in_flight = None
self.schedule_cache_rebuild()
raise e
def load_pg_dist_group(self) -> bool:
"""Read from the `pg_dist_node` table and put it into the local cache"""
with self._condition:
if not self._schedule_load_pg_dist_group:
return True
self._schedule_load_pg_dist_group = False
try:
rows = self.query('SELECT groupid, nodename, nodeport, noderole, nodeid FROM pg_catalog.pg_dist_node')
except Exception:
return False
pg_dist_group: Dict[int, PgDistTask] = {}
for row in rows:
if row[0] not in pg_dist_group:
pg_dist_group[row[0]] = PgDistTask(row[0], nodes=set(), event='after_promote')
pg_dist_group[row[0]].add(PgDistNode(*row[1:]))
with self._condition:
self._pg_dist_group = pg_dist_group
return True
def sync_meta_data(self, cluster: Cluster) -> None:
"""Maintain the ``pg_dist_node`` from the coordinator leader every heartbeat loop.
We can't always rely on REST API calls from worker nodes in order
to maintain `pg_dist_node`, therefore at least once per heartbeat
loop we make sure that workes registered in `self._pg_dist_group`
cache are matching the cluster view from DCS by creating tasks
the same way as it is done from the REST API."""
if not self.is_coordinator():
return
with self._condition:
if not self.is_alive():
self.start()
self.add_task('after_promote', CITUS_COORDINATOR_GROUP_ID, cluster,
self._postgresql.name, self._postgresql.connection_string)
for groupid, worker in cluster.workers.items():
leader = worker.leader
if leader and leader.conn_url\
and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running':
self.add_task('after_promote', groupid, worker, leader.name, leader.conn_url)
def find_task_by_groupid(self, groupid: int) -> Optional[int]:
for i, task in enumerate(self._tasks):
if task.groupid == groupid:
return i
def pick_task(self) -> Tuple[Optional[int], Optional[PgDistTask]]:
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
Tasks are picked by following priorities:
1. If there is already a transaction in progress, pick a task
that that will change already affected worker primary.
2. If the coordinator address should be changed - pick a task
with groupid=0 (coordinators are always in groupid 0).
3. Pick a task that is the oldest (first from the self._tasks)
"""
with self._condition:
if self._in_flight:
i = self.find_task_by_groupid(self._in_flight.groupid)
else:
while True:
i = self.find_task_by_groupid(CITUS_COORDINATOR_GROUP_ID) # set_coordinator
if i is None and self._tasks:
i = 0
if i is None:
break
task = self._tasks[i]
if task == self._pg_dist_group.get(task.groupid):
self._tasks.pop(i) # nothing to do because cached version of pg_dist_group already matches
else:
break
task = self._tasks[i] if i is not None else None
return i, task
def update_node(self, groupid: int, node: PgDistNode, cooldown: float = 10000) -> None:
if node.role not in ('primary', 'secondary', 'demoted'):
self.query('SELECT pg_catalog.citus_remove_node(%s, %s)', node.host, node.port)
elif node.nodeid is not None:
host = node.host + ('-demoted' if node.role == 'demoted' else '')
self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)',
node.nodeid, host, node.port, cooldown)
elif node.role != 'demoted':
node.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default')",
node.host, node.port, groupid, node.role)[0][0]
def update_group(self, task: PgDistTask, transaction: bool) -> None:
current_state = self._in_flight\
or self._pg_dist_group.get(task.groupid)\
or PgDistTask(task.groupid, set(), 'after_promote')
transitions = list(task.transition(current_state))
if transitions:
if not transaction and len(transitions) > 1:
self.query('BEGIN')
for node in transitions:
self.update_node(task.groupid, node, task.cooldown)
if not transaction and len(transitions) > 1:
task.failover = False
self.query('COMMIT')
def process_task(self, task: PgDistTask) -> bool:
"""Updates a single row in `pg_dist_group` table, optionally in a transaction.
The transaction is started if we do a demote of the worker node or before promoting the other worker if
there is no transaction in progress. And, the transaction is committed when the switchover/failover completed.
.. note:
The maximum lifetime of the transaction in progress is controlled outside of this method.
.. note:
Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread.
:param task: reference to a :class:`PgDistTask` object that represents a row to be updated/created.
:returns: ``True`` if the row was succesfully created/updated or transaction in progress
was committed as an indicator that the `self._pg_dist_group` cache should be updated,
or, if the new transaction was opened, this method returns `False`.
"""
if task.event == 'after_promote':
self.update_group(task, self._in_flight is not None)
if self._in_flight:
self.query('COMMIT')
task.failover = False
return True
else: # before_demote, before_promote
if task.timeout:
task.deadline = time.time() + task.timeout
if not self._in_flight:
self.query('BEGIN')
self.update_group(task, True)
return False
def process_tasks(self) -> None:
while True:
# Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread.
if not self._in_flight and not self.load_pg_dist_group():
break
i, task = self.pick_task()
if not task or i is None:
break
try:
update_cache = self.process_task(task)
except Exception as e:
logger.error('Exception when working with pg_dist_node: %r', e)
update_cache = None
with self._condition:
if self._tasks:
if update_cache:
self._pg_dist_group[task.groupid] = task
if update_cache is False: # an indicator that process_tasks has started a transaction
self._in_flight = task
else:
self._in_flight = None
if id(self._tasks[i]) == id(task):
self._tasks.pop(i)
task.wakeup()
def run(self) -> None:
while True:
try:
with self._condition:
if self._schedule_load_pg_dist_group:
timeout = -1
elif self._in_flight:
timeout = self._in_flight.deadline - time.time() if self._tasks else None
else:
timeout = -1 if self._tasks else None
if timeout is None or timeout > 0:
self._condition.wait(timeout)
elif self._in_flight:
logger.warning('Rolling back transaction. Last known status: %s', self._in_flight)
self.query('ROLLBACK')
self._in_flight = None
self.process_tasks()
except Exception:
logger.exception('run')
def _add_task(self, task: PgDistTask) -> bool:
with self._condition:
i = self.find_task_by_groupid(task.groupid)
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
if task.timeout is None:
# We don't want to override the already existing task created from REST API.
if i is not None and self._tasks[i].timeout is not None:
return False
# There is a little race condition with tasks created from REST API - the call made "before" the member
# key is updated in DCS. Therefore it is possible that :func:`sync_meta_data` will try to create a task
# based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_meta_data()
if self._in_flight and self._in_flight.groupid == task.groupid and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time():
return False
# Override already existing task for the same worker groupid
if i is not None:
if task != self._tasks[i]:
logger.debug('Overriding existing task: %s != %s', self._tasks[i], task)
self._tasks[i] = task
self._condition.notify()
return True
# Add the task to the list if Worker node state is different from the cached `pg_dist_group`
elif self._schedule_load_pg_dist_group or task != self._pg_dist_group.get(task.groupid)\
or self._in_flight and task.groupid == self._in_flight.groupid:
logger.debug('Adding the new task: %s', task)
self._tasks.append(task)
self._condition.notify()
return True
return False
@staticmethod
def _pg_dist_node(role: str, conn_url: str) -> Optional[PgDistNode]:
try:
r = urlparse(conn_url)
if r.hostname:
return PgDistNode(r.hostname, r.port or 5432, role)
except Exception as e:
logger.error('Failed to parse connection url %s: %r', conn_url, e)
def add_task(self, event: str, groupid: int, cluster: Cluster, leader_name: str, leader_url: str,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistTask]:
primary = self._pg_dist_node('demoted' if event == 'before_demote' else 'primary', leader_url)
if not primary:
return
task = PgDistTask(groupid, {primary}, event=event, timeout=timeout, cooldown=cooldown)
for member in cluster.members:
secondary = self._pg_dist_node('secondary', member.conn_url)\
if member.name != leader_name and member.is_running and member.conn_url else None
if secondary:
task.add(secondary)
return task if self._add_task(task) else None
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
if not self.is_alive():
return
worker = cluster.workers.get(event['group'])
if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url):
return logger.info('Discarding event %s', event)
task = self.add_task(event['type'], event['group'], worker,
worker.leader.name, worker.leader.conn_url,
event['timeout'], event['cooldown'] * 1000)
if task and event['type'] == 'before_demote':
task.wait()
def bootstrap(self) -> None:
"""Bootstrap handler.
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
"""
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
'options': '-c synchronous_commit=local -c statement_timeout=0'}
if self._config['database'] != self._postgresql.database:
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
except ProgrammingError as exc:
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
logger.debug('Exception when creating database: %r', exc)
else:
raise exc
finally:
conn.close()
conn_kwargs['dbname'] = self._config['database']
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
superuser = self._postgresql.config.superuser
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
if params:
cur.execute("INSERT INTO pg_catalog.pg_dist_authinfo VALUES"
"(0, pg_catalog.current_user(), %s)",
(self._postgresql.config.format_dsn(params),))
if self.is_coordinator():
r = urlparse(self._postgresql.connection_string)
cur.execute("SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default')",
(r.hostname, r.port or 5432))
finally:
conn.close()
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
"""Adjust GUCs in the current PostgreSQL configuration.
:param parameters: dictionary of GUCs, with key as GUC name and the corresponding value as current GUC value.
"""
# citus extension must be on the first place in shared_preload_libraries
shared_preload_libraries = list(filter(
lambda el: el and el != 'citus',
[p.strip() for p in parameters.get('shared_preload_libraries', '').split(',')]))
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
if parameters['max_prepared_transactions'] == 0:
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
# Resharding in Citus implemented using logical replication
parameters['wal_level'] = 'logical'
# Sometimes Citus needs to connect to the local postgres. We will do it the same way as Patroni does.
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
"""Check whether provided replication *slot* existing in the database should not be removed.
.. note::
MPP database may create replication slots for its own use, for example to migrate data between workers
using logical replication, and we don't want to suddenly drop them.
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
``plugin``.
:returns: ``True`` if the replication slots should not be removed, otherwise ``False``.
"""
if self._postgresql.is_primary() and slot['type'] == 'logical' and slot['database'] == self._config['database']:
m = CITUS_SLOT_NAME_RE.match(slot['name'])
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
return False
+1 -1
View File
@@ -176,7 +176,7 @@ class PostmasterProcess(psutil.Process):
return not self.is_running()
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
# These regexps are cross checked against versions PostgreSQL 9.1 .. 16
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|"
+37 -16
View File
@@ -13,6 +13,7 @@ from . import Postgresql
from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir, parse_history, parse_lsn
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, RemoteMember
logger = logging.getLogger(__name__)
@@ -101,12 +102,26 @@ class Rewind(object):
return 'not accessible or not healty'
def _get_checkpoint_end(self, timeline: int, lsn: int) -> int:
"""The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump
and parse the output. We are trying to read two records, and expect that it will fail to read the second one:
`pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0`
The error message contains information about LSN of the next record, which is exactly where checkpoint ends."""
"""Get the end of checkpoint record from WAL.
.. note::
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, is to read the record from file with the
help of ``pg_waldump`` and parse the output.
We are trying to read two records, and expect that it will fail to read the second record with message:
fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0; or
fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: expected at least 24, got 0
The error message contains information about LSN of the next record, which is exactly where checkpoint ends.
:param timeline: the checkpoint *timeline* from ``pg_controldata``.
:param lsn: the checkpoint *location* as :class:`int` from ``pg_controldata``.
:returns: the end of checkpoint record as :class:`int` or ``0`` if failed to parse ``pg_waldump`` output.
"""
lsn8 = format_lsn(lsn, True)
lsn_str = format_lsn(lsn)
out, err = self._postgresql.waldump(timeline, lsn_str, 2)
@@ -117,12 +132,17 @@ class Rewind(object):
if len(out) == 1 and len(err) == 1 and ', lsn: {0}, prev '.format(lsn8) in out[0] and pattern in err[0]:
i = err[0].find(pattern) + len(pattern)
j = err[0].find(": wanted ", i)
if j > -1:
try:
return parse_lsn(err[0][i:j])
except Exception as e:
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
# Message format depends on the major version:
# * expected at least -- starting from v16
# * wanted -- before v16
# We will simply check all possible combinations.
for pattern in (': expected at least ', ': wanted '):
j = err[0].find(pattern, i)
if j > -1:
try:
return parse_lsn(err[0][i:j])
except Exception as e:
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name)
logger.error(' stdout=%s', '\n'.join(out))
logger.error(' stderr=%s', '\n'.join(err))
@@ -190,9 +210,10 @@ class Rewind(object):
ret = member.conn_kwargs(auth)
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:
# Add target_session_attrs to make sure we hit the primary.
# It is not strictly necessary for starting from PostgreSQL v14, which made it possible
# to rewind from standby, but doing it from the real primary is always safer.
if self._postgresql.major_version >= 100000:
ret['target_session_attrs'] = 'read-write'
return ret
@@ -280,7 +301,7 @@ class Rewind(object):
"""After promote issue a CHECKPOINT from a new thread and asynchronously check the result.
In case if CHECKPOINT failed, just check that timeline in pg_control was updated."""
if self._state != REWIND_STATUS.CHECKPOINT and self._postgresql.is_leader():
if self._state != REWIND_STATUS.CHECKPOINT and self._postgresql.is_primary():
with self._checkpoint_task_lock:
if self._checkpoint_task:
with self._checkpoint_task:
@@ -398,7 +419,7 @@ class Rewind(object):
dsn = self._postgresql.config.format_dsn(r, True)
logger.info('running pg_rewind from %s', dsn)
restore_command = (self._postgresql.config.get('recovery_conf') or {}).get('restore_command') \
restore_command = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).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
+74 -73
View File
@@ -13,9 +13,11 @@ from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECK
from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir
from .. import global_config
from ..dcs import Cluster, Leader
from ..file_perm import pg_perm
from ..psycopg import OperationalError
from ..tags import Tags
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
@@ -186,7 +188,7 @@ class SlotsHandler:
self.pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
self.schedule()
def _query(self, sql: str, *params: Any) -> Union['cursor', 'Cursor[Any]']:
def _query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
"""Helper method for :meth:`Postgresql.query`.
:param sql: SQL statement to execute.
@@ -231,15 +233,16 @@ class SlotsHandler:
ret: Dict[str, int] = {}
slots_dict: Dict[str, Dict[str, Any]] = {slot['slot_name']: slot for slot in slots or []}
if slots_dict:
for name, value in slots_dict.items():
if name in self._replication_slots:
if compare_slots(value, self._replication_slots[name], 'datoid'):
if value['type'] == 'logical':
ret[name] = value['confirmed_flush_lsn']
self._copy_items(value, self._replication_slots[name])
for name, value in slots_dict.items():
if name in self._replication_slots:
if compare_slots(value, self._replication_slots[name], 'datoid'):
if value['type'] == 'logical':
ret[name] = value['confirmed_flush_lsn']
self._copy_items(value, self._replication_slots[name])
else:
self._schedule_load_slots = True
self._replication_slots[name]['restart_lsn'] = ret[name] = value['restart_lsn']
else:
self._schedule_load_slots = True
# It could happen that the slot was deleted in the background, we want to detect this case
if any(name not in slots_dict for name in self._replication_slots.keys()):
@@ -260,17 +263,19 @@ class SlotsHandler:
"""
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
replication_slots: Dict[str, Dict[str, Any]] = {}
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" \
pg_wal_lsn_diff = f"pg_catalog.pg_{self._postgresql.wal_name}_{self._postgresql.lsn_name}_diff"
extra = f", catalog_xmin, {pg_wal_lsn_diff}(confirmed_flush_lsn, '0/0')::bigint" \
if self._postgresql.major_version >= 100000 else ""
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
cursor = self._query(f'SELECT slot_name, slot_type, plugin, database, datoid'
f'{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}')
for r in cursor:
for r in self._query(f"SELECT slot_name, slot_type, {pg_wal_lsn_diff}(restart_lsn, '0/0')::bigint, plugin,"
f" database, datoid{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}"):
value = {'type': r[1]}
if r[1] == 'logical':
value.update(plugin=r[2], database=r[3], datoid=r[4])
value.update(plugin=r[3], database=r[4], datoid=r[5])
if self._postgresql.major_version >= 100000:
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
value.update(catalog_xmin=r[6], confirmed_flush_lsn=r[7])
else:
value['restart_lsn'] = r[2]
replication_slots[r[0]] = value
self._replication_slots = replication_slots
self._schedule_load_slots = False
@@ -286,18 +291,18 @@ class SlotsHandler:
:param name: name of the slot to ignore
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
otherwise will pass through and return result of :meth:`CitusHandler.ignore_replication_slot`.
otherwise will pass through and return result of :meth:`AbstractMPPHandler.ignore_replication_slot`.
"""
slot = self._replication_slots[name]
if cluster.config:
for matcher in cluster.config.ignore_slots_matchers:
for matcher in global_config.ignore_slots_matchers:
if (
(matcher.get("name") is None or matcher["name"] == name)
and all(not matcher.get(a) or matcher[a] == slot.get(a)
for a in ('database', 'plugin', 'type'))
):
return True
return self._postgresql.citus_handler.ignore_replication_slot(slot)
return self._postgresql.mpp_handler.ignore_replication_slot(slot)
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
"""Drop a named slot from Postgres.
@@ -308,18 +313,15 @@ class SlotsHandler:
``dropped`` is ``True`` if the slot was successfully dropped. If the slot was not found return
``False`` for both.
"""
cursor = self._query(('WITH slots AS (SELECT slot_name, active'
' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),'
' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),'
' true AS dropped FROM slots WHERE not active) '
'SELECT active, COALESCE(dropped, false) FROM slots'
' FULL OUTER JOIN dropped ON true'), name)
row = cursor.fetchone()
if not row:
row = (False, False)
return row[0], row[1]
rows = self._query(('WITH slots AS (SELECT slot_name, active'
' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),'
' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),'
' true AS dropped FROM slots WHERE not active) '
'SELECT active, COALESCE(dropped, false) FROM slots'
' FULL OUTER JOIN dropped ON true'), name)
return (rows[0][0], rows[0][1]) if rows else (False, False)
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any]) -> None:
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
.. note::
@@ -330,11 +332,10 @@ class SlotsHandler:
:param cluster: cluster state information object.
:param slots: dictionary of desired slot names as keys with slot attributes as a dictionary value, if known.
:param paused: ``True`` if the patroni cluster is currently in a paused state.
"""
# drop old replication slots which are not presented in desired slots.
for name in set(self._replication_slots) - set(slots):
if not paused and not self.ignore_replication_slot(cluster, name):
if not global_config.is_paused and not self.ignore_replication_slot(cluster, name):
active, dropped = self.drop_replication_slot(name)
if dropped:
logger.info("Dropped unknown replication slot '%s'", name)
@@ -357,7 +358,7 @@ class SlotsHandler:
self._schedule_load_slots = True
def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None:
"""Create any missing physical replication *slots*.
"""Create or advance physical replication *slots*.
Any failures are logged and do not interrupt creation of all *slots*.
@@ -366,7 +367,9 @@ class SlotsHandler:
"""
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
for name, value in slots.items():
if name not in self._replication_slots and value['type'] == 'physical':
if value['type'] != 'physical':
continue
if name not in self._replication_slots:
try:
self._query(f"SELECT pg_catalog.pg_create_physical_replication_slot(%s{immediately_reserve})"
f" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
@@ -375,6 +378,15 @@ class SlotsHandler:
except Exception:
logger.exception("Failed to create physical replication slot '%s'", name)
self._schedule_load_slots = True
elif self._postgresql.can_advance_slots and self._replication_slots[name]['type'] == 'physical':
value['restart_lsn'] = self._replication_slots[name]['restart_lsn']
lsn = value.get('lsn')
if lsn and lsn > value['restart_lsn']: # The slot has feedback in DCS and needs to be advanced
try:
lsn = format_lsn(lsn)
self._query("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", name, lsn)
except Exception as exc:
logger.error("Error while advancing replication slot %s to position '%s': %r", name, lsn, exc)
@contextmanager
def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
@@ -388,8 +400,7 @@ class SlotsHandler:
:yields: connection cursor object, note implementation varies depending on version of :mod:`psycopg`.
"""
conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs.update(kwargs)
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs, **kwargs}
with get_connection_cursor(**conn_kwargs) as cur:
yield cur
@@ -439,7 +450,7 @@ class SlotsHandler:
self._advance = SlotsAdvanceThread(self)
return self._advance.schedule(slots)
def _ensure_logical_slots_replica(self, cluster: Cluster, slots: Dict[str, Any]) -> List[str]:
def _ensure_logical_slots_replica(self, slots: Dict[str, Any]) -> List[str]:
"""Update logical *slots* on replicas.
If the logical slot already exists, copy state information into the replication slots structure stored in the
@@ -449,7 +460,6 @@ class SlotsHandler:
As logical slots can only be created when the primary is available, pass the list of slots that need to be
copied back to the caller. They will be created on replicas with :meth:`SlotsHandler.copy_logical_slots`.
:param cluster: object containing stateful information for the cluster.
:param slots: A dictionary mapping slot name to slot attributes. This method only considers a slot
if the value is a dictionary with the key ``type`` and a value of ``logical``.
@@ -464,15 +474,13 @@ class SlotsHandler:
continue
# If the logical already exists, copy some information about it into the original structure
if self._replication_slots.get(name, {}).get('datoid'):
if name in self._replication_slots and compare_slots(value, self._replication_slots[name]):
self._copy_items(self._replication_slots[name], value)
if cluster.slots and name in cluster.slots:
try: # Skip slots that don't need to be advanced
if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
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 cluster.slots and name in cluster.slots: # We want to copy only slots with feedback in a DCS
if 'lsn' in value and value['confirmed_flush_lsn'] < value['lsn']: # The slot has feedback in DCS
# Skip slots that don't need to be advanced
advance_slots[value['database']][name] = value['lsn']
elif name not in self._replication_slots and 'lsn' in value:
# We want to copy only slots with feedback in a DCS
create_slots.append(name)
# Slots to be copied from the primary should be removed from the *slots* structure,
@@ -485,42 +493,37 @@ class SlotsHandler:
self._schedule_load_slots = True
return create_slots + copy_slots
def sync_replication_slots(self, cluster: Cluster, nofailover: bool,
replicatefrom: Optional[str] = None, paused: bool = False) -> List[str]:
def sync_replication_slots(self, cluster: Cluster, tags: Tags) -> List[str]:
"""During the HA loop read, check and alter replication slots found in the cluster.
Read physical and logical slots found on the primary, then compare to those configured in the DCS.
Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS.
Drop any slots that do not match those required by configuration and are not configured as permanent.
Create any missing physical slots. If we are the leader then logical slots too, otherwise if logical slots
are known and active create them on replica nodes.
Create any missing physical slots, or advance their position according to feedback stored in DCS.
If we are the primary then create logical slots, otherwise if logical slots are known and active create
them on replica nodes by copying slot files from the primary.
:param cluster: object containing stateful information for the cluster.
:param nofailover: ``True`` if this node has been tagged to not be a failover candidate.
:param replicatefrom: the tag containing the node to replicate from.
:param paused: ``True`` if the cluster is in maintenance mode.
:param tags: reference to an object implementing :class:`Tags` interface.
:returns: list of logical replication slots names that should be copied from the primary.
"""
ret = []
if self._postgresql.major_version >= 90400 and self._postgresql.global_config and cluster.config:
if self._postgresql.major_version >= 90400 and cluster.config:
try:
self.load_replication_slots()
slots = cluster.get_replication_slots(
self._postgresql.name, self._postgresql.role, nofailover, self._postgresql.major_version,
is_standby_cluster=self._postgresql.global_config.is_standby_cluster, show_error=True)
slots = cluster.get_replication_slots(self._postgresql, tags, show_error=True)
self._drop_incorrect_slots(cluster, slots, paused)
self._drop_incorrect_slots(cluster, slots)
self._ensure_physical_slots(slots)
if self._postgresql.is_leader():
if self._postgresql.is_primary():
self._logical_slots_processing_queue.clear()
self._ensure_logical_slots_primary(slots)
elif cluster.slots and slots:
self.check_logical_slots_readiness(cluster, replicatefrom)
ret = self._ensure_logical_slots_replica(cluster, slots)
else:
self.check_logical_slots_readiness(cluster, tags)
ret = self._ensure_logical_slots_replica(slots)
self._replication_slots = slots
except Exception:
@@ -545,7 +548,7 @@ class SlotsHandler:
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
yield cur
def check_logical_slots_readiness(self, cluster: Cluster, replicatefrom: Optional[str]) -> bool:
def check_logical_slots_readiness(self, cluster: Cluster, tags: Tags) -> bool:
"""Determine whether all known logical slots are synchronised from the leader.
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
@@ -554,13 +557,13 @@ class SlotsHandler:
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
:param cluster: object containing stateful information for the cluster.
:param replicatefrom: name of the member that should be used to replicate from.
:param tags: reference to an object implementing :class:`Tags` interface.
:returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise.
"""
catalog_xmin = None
if self._logical_slots_processing_queue and cluster.leader:
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
slot_name = cluster.get_slot_name_on_primary(self._postgresql.name, tags)
try:
with self._get_leader_connection_cursor(cluster.leader) as cur:
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
@@ -601,11 +604,8 @@ class SlotsHandler:
# Replica isn't streaming or the hot_standby_feedback isn't enabled
try:
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
row = cur.fetchone()
if row and not row[0]:
logger.error('Logical slot failover requires "hot_standby_feedback".'
' Please check postgresql.auto.conf')
if not self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")[0][0]:
logger.error('Logical slot failover requires "hot_standby_feedback". Please check postgresql.auto.conf')
except Exception as e:
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
return False
@@ -641,16 +641,17 @@ class SlotsHandler:
if standby_logical_slot:
logger.info('Logical slot %s is safe to be used after a failover', name)
def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None:
def copy_logical_slots(self, cluster: Cluster, tags: Tags, create_slots: List[str]) -> None:
"""Create logical replication slots on standby nodes.
:param cluster: object containing stateful information for the cluster.
:param tags: reference to an object implementing :class:`Tags` interface.
:param create_slots: list of slot names to copy from the primary.
"""
leader = cluster.leader
if not leader:
return
slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version)
slots = cluster.get_replication_slots(self._postgresql, tags, role='replica')
copy_slots: Dict[str, Dict[str, Any]] = {}
with self._get_leader_connection_cursor(leader) as cur:
try:
+6 -8
View File
@@ -5,6 +5,7 @@ import time
from copy import deepcopy
from typing import Collection, List, NamedTuple, Tuple, TYPE_CHECKING
from .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import Cluster
from ..psycopg import quote_ident as _quote_ident
@@ -182,7 +183,7 @@ class _ReplicaList(List[_Replica]):
swapping, but only if lag on this member is exceeding a threshold (``maximum_lag_on_syncnode``).
:ivar max_lsn: maximum value of ``_Replica.lsn`` among all values. In case if there is just one
element in the list we take value of ``pg_current_wal_lsn()``.
element in the list we take value of ``pg_current_wal_flush_lsn()``.
"""
def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
@@ -209,7 +210,7 @@ class _ReplicaList(List[_Replica]):
# 2. can be mapped to a ``Member`` of the ``Cluster``:
# a. ``Member`` doesn't have ``nosync`` tag set;
# b. PostgreSQL on the member is known to be running and accepting client connections.
if member and row[sort_col] is not None and member.is_running and not member.tags.get('nosync', False):
if member and row[sort_col] is not None and member.is_running and not member.nosync:
self.append(_Replica(row['pid'], row['application_name'],
row['sync_state'], row[sort_col], bool(member.nofailover)))
@@ -303,11 +304,8 @@ END;$$""")
replica_list = _ReplicaList(self._postgresql, cluster)
self._process_replica_readiness(cluster, replica_list)
if TYPE_CHECKING: # pragma: no cover
assert self._postgresql.global_config is not None
sync_node_count = self._postgresql.global_config.synchronous_node_count\
if self._postgresql.supports_multiple_sync else 1
sync_node_maxlag = self._postgresql.global_config.maximum_lag_on_syncnode
sync_node_count = global_config.synchronous_node_count if self._postgresql.supports_multiple_sync else 1
sync_node_maxlag = global_config.maximum_lag_on_syncnode
candidates = CaseInsensitiveSet()
sync_nodes = CaseInsensitiveSet()
@@ -339,7 +337,7 @@ END;$$""")
sync_param = next(iter(sync), None)
if not (self._postgresql.config.set_synchronous_standby_names(sync_param)
and self._postgresql.state == 'running' and self._postgresql.is_leader()) or has_asterisk:
and self._postgresql.state == 'running' and self._postgresql.is_primary()) or has_asterisk:
return
time.sleep(0.1) # Usualy it takes 1ms to reload postgresql.conf, but we will give it 100ms
+70 -78
View File
@@ -1,11 +1,11 @@
import abc
from copy import deepcopy
import logging
import os
import yaml
from typing import Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Type, Union
from .available_parameters import get_validator_files, PathLikeObj
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..exceptions import PatroniException
from ..utils import parse_bool, parse_int, parse_real
@@ -258,10 +258,10 @@ class InvalidGucValidatorsFile(PatroniException):
"""Raised when reading or parsing of a YAML file faces an issue."""
def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
def _read_postgres_gucs_validators_file(file: PathLikeObj) -> Dict[str, Any]:
"""Read an YAML file and return the corresponding Python object.
:param file: path to the file to be read. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
:param file: path-like object to read from. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
return ``None``.
@@ -270,7 +270,7 @@ def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
:class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
"""
try:
with open(file, encoding='UTF-8') as stream:
with file.open(encoding='UTF-8') as stream:
return yaml.safe_load(stream)
except Exception as exc:
raise InvalidGucValidatorsFile(
@@ -290,7 +290,7 @@ def _load_postgres_gucs_validators() -> None:
Any problem faced while reading or parsing files will be logged as a ``WARNING`` by the child function, and the
corresponding file or validator will be ignored.
By default Patroni only ships the file ``0_postgres.yml``, which contains Community Postgres GUCs validators, but
By default, Patroni only ships the file ``0_postgres.yml``, which contains Community Postgres GUCs validators, but
that behavior can be extended. For example: if a vendor wants to add GUC validators to Patroni for covering a custom
Postgres build, then they can create their custom YAML files under ``available_parameters`` directory.
@@ -300,8 +300,10 @@ def _load_postgres_gucs_validators() -> None:
writes them to ``postgresql.conf`` if running PG 12 and above).
Then, each of these sections, if specified, may contain one or more attributes with the following structure:
* key: the name of a GUC;
* value: a list of validators. Each item in the list must contain a ``type`` attribute, which must be one among:
* ``Bool``; or
* ``Integer``; or
* ``Real``; or
@@ -313,6 +315,7 @@ def _load_postgres_gucs_validators() -> None:
class in this module.
.. seealso::
* :class:`Bool`;
* :class:`Integer`;
* :class:`Real`;
@@ -325,77 +328,64 @@ def _load_postgres_gucs_validators() -> None:
This is a sample content for an YAML file based on Postgres GUCs, showing each of the supported types and
sections:
```yaml
parameters:
archive_command:
- type: String
version_from: 90300
version_till: null
archive_mode:
- type: Bool
version_from: 90300
version_till: 90500
- type: EnumBool
version_from: 90500
version_till: null
possible_values:
- always
archive_timeout:
- type: Integer
version_from: 90300
version_till: null
min_val: 0
max_val: 1073741823
unit: s
autovacuum_vacuum_cost_delay:
- type: Integer
version_from: 90300
version_till: 120000
min_val: -1
max_val: 100
unit: ms
- type: Real
version_from: 120000
version_till: null
min_val: -1
max_val: 100
unit: ms
client_min_messages:
- type: Enum
version_from: 90300
version_till: null
possible_values:
- debug5
- debug4
- debug3
- debug2
- debug1
- log
- notice
- warning
- error
recovery_parameters:
archive_cleanup_command:
- type: String
version_from: 90300
version_till: null
```
.. code-block:: yaml
parameters:
archive_command:
- type: String
version_from: 90300
version_till: null
archive_mode:
- type: Bool
version_from: 90300
version_till: 90500
- type: EnumBool
version_from: 90500
version_till: null
possible_values:
- always
archive_timeout:
- type: Integer
version_from: 90300
version_till: null
min_val: 0
max_val: 1073741823
unit: s
autovacuum_vacuum_cost_delay:
- type: Integer
version_from: 90300
version_till: 120000
min_val: -1
max_val: 100
unit: ms
- type: Real
version_from: 120000
version_till: null
min_val: -1
max_val: 100
unit: ms
client_min_messages:
- type: Enum
version_from: 90300
version_till: null
possible_values:
- debug5
- debug4
- debug3
- debug2
- debug1
- log
- notice
- warning
- error
recovery_parameters:
archive_cleanup_command:
- type: String
version_from: 90300
version_till: null
"""
conf_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'available_parameters',
)
yaml_files: List[str] = []
for root, _, files in os.walk(conf_dir):
for file in sorted(files):
full_path = os.path.join(root, file)
if file.lower().endswith(('.yml', '.yaml')):
yaml_files.append(full_path)
else:
logger.info('Ignored a non-YAML file found under `available_parameters` directory: `%s`.', full_path)
for file in yaml_files:
for file in get_validator_files():
try:
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
except InvalidGucValidatorsFile as exc:
@@ -434,13 +424,15 @@ def _transform_parameter_value(validators: MutableMapping[str, Tuple[_Transforma
:param value: value of the Postgres GUC.
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
GUC. Used for a couple purposes:
* Disallow writing GUCs to ``postgresql.conf`` (or ``recovery.conf``) that does not exist in Postgres *version*;
* Avoid ignoring GUC *name* if it does not have a validator in *validators*, but is a valid GUC in Postgres
*version*.
*version*.
:returns: the return value may be one among:
* *value* transformed to the expected format for GUC *name* in Postgres *version*, if *name* is present in
*available_gucs* and has a validator in *validators* for the corresponding Postgres *version*; or
* *value* transformed to the expected format for GUC *name* in Postgres *version*, if *name* is present
in *available_gucs* and has a validator in *validators* for the corresponding Postgres *version*; or
* The own *value* if *name* is present in *available_gucs* but not in *validators*; or
* ``None`` if *name* is not present in *available_gucs*.
"""
+3 -2
View File
@@ -42,7 +42,8 @@ try:
value.prepare(conn)
return value.getquoted().decode('utf-8')
except ImportError:
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
from psycopg import connect as __connect # pyright: ignore [reportUnknownVariableType]
from psycopg import sql, Error, DatabaseError, OperationalError, ProgrammingError
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
@@ -56,7 +57,7 @@ except ImportError:
:returns: a connection to the database.
"""
ret = __connect(dsn or "", **kwargs)
ret: 'Connection[Any]' = __connect(dsn or "", **kwargs)
setattr(ret, 'server_version', ret.pgconn.server_version) # compatibility with psycopg2
return ret
+1
View File
@@ -0,0 +1 @@
"""Create :mod:`patroni.scripts.barman`."""
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python
"""Perform operations on Barman through ``pg-backup-api``.
The actual operations are implemented by separate modules. This module only
builds the CLI that makes an interface with the actual commands.
.. note::
See :class:ExitCode` for possible exit codes of this main script.
"""
from argparse import ArgumentParser
from enum import IntEnum
import logging
import sys
from .config_switch import run_barman_config_switch
from .recover import run_barman_recover
from .utils import ApiNotOk, PgBackupApi, set_up_logging
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar NO_COMMAND: if no sub-command of ``patroni_barman`` application has
been selected by the user.
:cvar API_NOT_OK: ``pg-backup-api`` status is not ``OK``.
"""
NO_COMMAND = -1
API_NOT_OK = -2
def main() -> None:
"""Entry point of ``patroni_barman`` application.
Implements the parser for the application and for its sub-commands.
The script exit code may be one of:
* :attr:`ExitCode.NO_COMMAND`: if no sub-command was specified in the
``patroni_barman`` call;
* :attr:`ExitCode.API_NOT_OK`: if ``pg-backup-api`` is not correctly up and
running;
* Value returned by :func:`~patroni.scripts.barman.config_switch.run_barman_config_switch`,
if running ``patroni_barman config-switch``;
* Value returned by :func:`~patroni.scripts.barman.recover.run_barman_recover`,
if running ``patroni_barman recover``.
The called sub-command is expected to exit execution once finished using
its own set of exit codes.
"""
parser = ArgumentParser(
description=(
"Wrapper application for pg-backup-api. Communicate with the API "
"running at the given URL to perform remote Barman operations."
),
)
parser.add_argument(
"--api-url",
type=str,
required=True,
help="URL to reach the pg-backup-api, e.g. 'http://localhost:7480'",
dest="api_url",
)
parser.add_argument(
"--cert-file",
type=str,
required=False,
help="Certificate to authenticate against the API, if required.",
dest="cert_file",
)
parser.add_argument(
"--key-file",
type=str,
required=False,
help="Certificate key to authenticate against the API, if required.",
dest="key_file",
)
parser.add_argument(
"--retry-wait",
type=int,
required=False,
default=2,
help="How long in seconds to wait before retrying a failed "
"pg-backup-api request (default: '%(default)s')",
dest="retry_wait",
)
parser.add_argument(
"--max-retries",
type=int,
required=False,
default=5,
help="Maximum number of retries when receiving malformed responses "
"from the pg-backup-api (default: '%(default)s')",
dest="max_retries",
)
parser.add_argument(
"--log-file",
type=str,
required=False,
help="File where to log messages produced by this application, if any.",
dest="log_file",
)
subparsers = parser.add_subparsers(title="Sub-commands")
recover_parser = subparsers.add_parser(
"recover",
help="Remote 'barman recover'",
description="Restore a Barman backup of a given Barman server"
)
recover_parser.add_argument(
"--barman-server",
type=str,
required=True,
help="Name of the Barman server from which to restore the backup.",
dest="barman_server",
)
recover_parser.add_argument(
"--backup-id",
type=str,
required=False,
default="latest",
help="ID of the Barman backup to be restored. You can use any value "
"supported by 'barman recover' command "
"(default: '%(default)s')",
dest="backup_id",
)
recover_parser.add_argument(
"--ssh-command",
type=str,
required=True,
help="Value to be passed as '--remote-ssh-command' to 'barman recover'.",
dest="ssh_command",
)
recover_parser.add_argument(
"--data-directory",
"--datadir",
type=str,
required=True,
help="Destination path where to restore the barman backup in the "
"local host.",
dest="data_directory",
)
recover_parser.add_argument(
"--loop-wait",
type=int,
required=False,
default=10,
help="How long to wait before checking again the status of the "
"recovery process, in seconds. Use higher values if your "
"recovery is expected to take long (default: '%(default)s')",
dest="loop_wait",
)
recover_parser.set_defaults(func=run_barman_recover)
config_switch_parser = subparsers.add_parser(
"config-switch",
help="Remote 'barman config-switch'",
description="Switch the configuration of a given Barman server. "
"Intended to be used as a 'on_role_change' callback."
)
config_switch_parser.add_argument(
"action",
type=str,
choices=["on_role_change"],
help="Name of the callback (automatically filled by Patroni)",
)
config_switch_parser.add_argument(
"role",
type=str,
choices=["master", "primary", "promoted", "standby_leader", "replica",
"demoted"],
help="Name of the new role of this node (automatically filled by "
"Patroni)",
)
config_switch_parser.add_argument(
"cluster",
type=str,
help="Name of the Patroni cluster involved in the callback "
"(automatically filled by Patroni)",
)
config_switch_parser.add_argument(
"--barman-server",
type=str,
required=True,
help="Name of the Barman server which config is to be switched.",
dest="barman_server",
)
group = config_switch_parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--barman-model",
type=str,
help="Name of the Barman config model to be applied to the server.",
dest="barman_model",
)
group.add_argument(
"--reset",
action="store_true",
help="Unapply the currently active model for the server, if any.",
dest="reset",
)
config_switch_parser.add_argument(
"--switch-when",
type=str,
required=True,
default="promoted",
choices=["promoted", "demoted", "always"],
help="Controls under which circumstances the 'on_role_change' callback "
"should actually switch config in Barman. 'promoted' means the "
"'role' is either 'master', 'primary' or 'promoted'. 'demoted' "
"means the 'role' is either 'replica' or 'demoted' "
"(default: '%(default)s')",
dest="switch_when",
)
config_switch_parser.set_defaults(func=run_barman_config_switch)
args, _ = parser.parse_known_args()
set_up_logging(args.log_file)
if not hasattr(args, "func"):
parser.print_help()
sys.exit(ExitCode.NO_COMMAND)
api = None
try:
api = PgBackupApi(args.api_url, args.cert_file, args.key_file,
args.retry_wait, args.max_retries)
except ApiNotOk as exc:
logging.error("pg-backup-api is not working: %r", exc)
sys.exit(ExitCode.API_NOT_OK)
sys.exit(args.func(api, args))
if __name__ == "__main__":
main()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python
"""Implements ``patroni_barman config-switch`` sub-command.
Apply a Barman configuration model through ``pg-backup-api``.
This sub-command is specially useful as a ``on_role_change`` callback to change
Barman configuration in response to failovers and switchovers. Check the output
of ``--help`` to understand the parameters supported by the sub-command.
It requires that you have previously configured a Barman server and Barman
config models, and that you have ``pg-backup-api`` configured and running in
the same host as Barman.
Refer to :class:`ExitCode` for possible exit codes of this sub-command.
"""
from argparse import Namespace
from enum import IntEnum
import logging
import time
from typing import Optional, TYPE_CHECKING
from .utils import OperationStatus, RetriesExceeded
if TYPE_CHECKING: # pragma: no cover
from .utils import PgBackupApi
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar CONFIG_SWITCH_DONE: config switch was successfully performed.
:cvar CONFIG_SWITCH_SKIPPED: if the execution was skipped because of not
matching user expectations.
:cvar CONFIG_SWITCH_FAILED: config switch faced an issue.
:cvar HTTP_ERROR: an error has occurred while communicating with
``pg-backup-api``
:cvar INVALID_ARGS: an invalid set of arguments has been given to the
operation.
"""
CONFIG_SWITCH_DONE = 0
CONFIG_SWITCH_SKIPPED = 1
CONFIG_SWITCH_FAILED = 2
HTTP_ERROR = 3
INVALID_ARGS = 4
def _should_skip_switch(args: Namespace) -> bool:
"""Check if we should skip the config switch operation.
:param args: arguments received from the command-line of
``patroni_barman config-switch`` command.
:returns: if the operation should be skipped.
"""
if args.switch_when == "promoted":
return args.role not in {"master", "primary", "promoted"}
if args.switch_when == "demoted":
return args.role not in {"replica", "demoted"}
return False
def _switch_config(api: "PgBackupApi", barman_server: str,
barman_model: Optional[str], reset: Optional[bool]) -> int:
"""Switch configuration of Barman server through ``pg-backup-api``.
.. note::
If requests to ``pg-backup-api`` fail recurrently or we face HTTP
errors, then exit with :attr:`ExitCode.HTTP_ERROR`.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param barman_server: name of the Barman server which config is to be
switched.
:param barman_model: name of the Barman model to be applied to the server,
if any.
:param reset: ``True`` if you would like to unapply the currently active
model for the server, if any.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
operation_id = None
try:
operation_id = api.create_config_switch_operation(
barman_server,
barman_model,
reset,
)
except RetriesExceeded as exc:
logging.error("An issue was faced while trying to create a config "
"switch operation: %r", exc)
return ExitCode.HTTP_ERROR
logging.info("Created the config switch operation with ID %s",
operation_id)
status = None
while True:
try:
status = api.get_operation_status(barman_server, operation_id)
except RetriesExceeded:
logging.error("Maximum number of retries exceeded, exiting.")
return ExitCode.HTTP_ERROR
if status != OperationStatus.IN_PROGRESS:
break
logging.info("Config switch operation %s is still in progress",
operation_id)
time.sleep(5)
if status == OperationStatus.DONE:
logging.info("Config switch operation finished successfully.")
return ExitCode.CONFIG_SWITCH_DONE
else:
logging.error("Config switch operation failed.")
return ExitCode.CONFIG_SWITCH_FAILED
def run_barman_config_switch(api: "PgBackupApi", args: Namespace) -> int:
"""Run a remote ``barman config-switch`` through the ``pg-backup-api``.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param args: arguments received from the command-line of
``patroni_barman config-switch`` command.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
if _should_skip_switch(args):
logging.info("Config switch operation was skipped (role=%s, "
"switch_when=%s).", args.role, args.switch_when)
return ExitCode.CONFIG_SWITCH_SKIPPED
if not bool(args.barman_model) ^ bool(args.reset):
logging.error("One, and only one among 'barman_model' ('%s') and "
"'reset' ('%s') should be given", args.barman_model, args.reset)
return ExitCode.INVALID_ARGS
return _switch_config(api, args.barman_server, args.barman_model, args.reset)
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python
"""Implements ``patroni_barman recover`` sub-command.
Restore a Barman backup to the local node through ``pg-backup-api``.
This sub-command can be used both as a custom bootstrap method, and as a custom
create replica method. Check the output of ``--help`` to understand the
parameters supported by the sub-command. ``--datadir`` is a special parameter
and it is automatically filled by Patroni in both cases.
It requires that you have previously configured a Barman server, and that you
have ``pg-backup-api`` configured and running in the same host as Barman.
Refer to :class:`ExitCode` for possible exit codes of this sub-command.
"""
from argparse import Namespace
from enum import IntEnum
import logging
import time
from typing import TYPE_CHECKING
from .utils import OperationStatus, RetriesExceeded
if TYPE_CHECKING: # pragma: no cover
from .utils import PgBackupApi
class ExitCode(IntEnum):
"""Possible exit codes of this script.
:cvar RECOVERY_DONE: backup was successfully restored.
:cvar RECOVERY_FAILED: recovery of the backup faced an issue.
:cvar HTTP_ERROR: an error has occurred while communicating with
``pg-backup-api``
"""
RECOVERY_DONE = 0
RECOVERY_FAILED = 1
HTTP_ERROR = 2
def _restore_backup(api: "PgBackupApi", barman_server: str, backup_id: str,
ssh_command: str, data_directory: str,
loop_wait: int) -> int:
"""Restore the configured Barman backup through ``pg-backup-api``.
.. note::
If requests to ``pg-backup-api`` fail recurrently or we face HTTP
errors, then exit with :attr:`ExitCode.HTTP_ERROR`.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param barman_server: name of the Barman server which backup is to be
restored.
:param backup_id: ID of the backup from the Barman server.
:param ssh_command: SSH command to connect from the Barman host to the
target host.
:param data_directory: path to the Postgres data directory where to restore
the backup in.
:param loop_wait: how long in seconds to wait before checking again the
status of the recovery process. Higher values are useful for backups
that are expected to take longer to restore.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
operation_id = None
try:
operation_id = api.create_recovery_operation(
barman_server,
backup_id,
ssh_command,
data_directory,
)
except RetriesExceeded as exc:
logging.error("An issue was faced while trying to create a recovery "
"operation: %r", exc)
return ExitCode.HTTP_ERROR
logging.info("Created the recovery operation with ID %s", operation_id)
status = None
while True:
try:
status = api.get_operation_status(barman_server, operation_id)
except RetriesExceeded:
logging.error("Maximum number of retries exceeded, exiting.")
return ExitCode.HTTP_ERROR
if status != OperationStatus.IN_PROGRESS:
break
logging.info("Recovery operation %s is still in progress",
operation_id)
time.sleep(loop_wait)
if status == OperationStatus.DONE:
logging.info("Recovery operation finished successfully.")
return ExitCode.RECOVERY_DONE
else:
logging.error("Recovery operation failed.")
return ExitCode.RECOVERY_FAILED
def run_barman_recover(api: "PgBackupApi", args: Namespace) -> int:
"""Run a remote ``barman recover`` through the ``pg-backup-api``.
:param api: a :class:`PgBackupApi` instance to handle communication with
the API.
:param args: arguments received from the command-line of
``patroni_barman recover`` command.
:returns: the return code to be used when exiting the ``patroni_barman``
application. Refer to :class:`ExitCode`.
"""
return _restore_backup(api, args.barman_server, args.backup_id,
args.ssh_command, args.data_directory,
args.loop_wait)
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python
"""Utilitary stuff to be used by Barman related scripts."""
from enum import IntEnum
import json
import logging
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
import time
from urllib.parse import urljoin
from urllib3 import PoolManager
from urllib3.exceptions import MaxRetryError
from urllib3.response import HTTPResponse
class RetriesExceeded(Exception):
"""Maximum number of retries exceeded."""
def retry(exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]]) \
-> Any:
"""Retry an operation n times if expected *exceptions* are faced.
.. note::
Should be used as a decorator of a class' method as it expects the
first argument to be a class instance.
The class which method is going to be decorated should contain a couple
attributes:
* ``max_retries``: maximum retry attempts before failing;
* ``retry_wait``: how long in seconds to wait before retrying.
:param exceptions: exceptions that could trigger a retry attempt.
:raises:
:exc:`RetriesExceeded`: if the maximum number of attempts has been
exhausted.
"""
def decorator(func: Callable[..., Any]) -> Any:
def inner_func(instance: object, *args: Any, **kwargs: Any) -> Any:
times: int = getattr(instance, "max_retries")
retry_wait: int = getattr(instance, "retry_wait")
method_name = f"{instance.__class__.__name__}.{func.__name__}"
attempt = 1
while attempt <= times:
try:
return func(instance, *args, **kwargs)
except exceptions as exc:
logging.warning("Attempt %d of %d on method %s failed "
"with %r.",
attempt, times, method_name, exc)
attempt += 1
time.sleep(retry_wait)
raise RetriesExceeded("Maximum number of retries exceeded for "
f"method {method_name}.")
return inner_func
return decorator
def set_up_logging(log_file: Optional[str] = None) -> None:
"""Set up logging to file, if *log_file* is given, otherwise to console.
:param log_file: file where to log messages, if any.
"""
logging.basicConfig(filename=log_file, level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
class OperationStatus(IntEnum):
"""Possible status of ``pg-backup-api`` operations.
:cvar IN_PROGRESS: the operation is still ongoing.
:cvar FAILED: the operation failed.
:cvar DONE: the operation finished successfully.
"""
IN_PROGRESS = 0
FAILED = 1
DONE = 2
class ApiNotOk(Exception):
"""The ``pg-backup-api`` is not currently up and running."""
class PgBackupApi:
"""Facilities for communicating with the ``pg-backup-api``.
:ivar api_url: base URL to reach the ``pg-backup-api``.
:ivar cert_file: certificate to authenticate against the ``pg-backup-api``,
if required.
:ivar key_file: certificate key to authenticate against the
``pg-backup-api``, if required.
:ivar retry_wait: how long in seconds to wait before retrying a failed
request to the ``pg-backup-api``.
:ivar max_retries: maximum number of retries when ``pg-backup-api`` returns
malformed responses.
:ivar http: a HTTP pool manager for performing web requests.
"""
def __init__(self, api_url: str, cert_file: Optional[str],
key_file: Optional[str], retry_wait: int,
max_retries: int) -> None:
"""Create a new instance of :class:`BarmanRecover`.
Make sure the ``pg-backup-api`` is reachable and running fine.
.. note::
When using any method which send requests to the API, be aware that
they might raise :exc:`RetriesExceeded` upon HTTP request errors.
Similarly, when instantiating this class you may face an
:exc:`ApiNotOk`, if the API is down or returns a bogus status.
:param api_url: base URL to reach the ``pg-backup-api``.
:param cert_file: certificate to authenticate against the
``pg-backup-api``, if required.
:param key_file: certificate key to authenticate against the
``pg-backup-api``, if required.
:param retry_wait: how long in seconds to wait before retrying a failed
request to the ``pg-backup-api``.
:param max_retries: maximum number of retries when ``pg-backup-api``
returns malformed responses.
"""
self.api_url = api_url
self.cert_file = cert_file
self.key_file = key_file
self.retry_wait = retry_wait
self.max_retries = max_retries
self._http = PoolManager(cert_file=cert_file, key_file=key_file)
self._ensure_api_ok()
def _build_full_url(self, url_path: str) -> str:
"""Build the full URL by concatenating *url_path* with the base URL.
:param url_path: path to be accessed in the ``pg-backup-api``.
:returns: the full URL after concatenating.
"""
return urljoin(self.api_url, url_path)
@staticmethod
def _deserialize_response(response: HTTPResponse) -> Any:
"""Retrieve body from *response* as a deserialized JSON object.
:param response: response from which JSON body will be deserialized.
:returns: the deserialized JSON body.
"""
return json.loads(response.data.decode("utf-8"))
@staticmethod
def _serialize_request(body: Any) -> Any:
"""Serialize a request body.
:param body: content of the request body to be serialized.
:returns: the serialized request body.
"""
return json.dumps(body).encode("utf-8")
def _get_request(self, url_path: str) -> Any:
"""Perform a ``GET`` request to *url_path*.
:param url_path: URL to perform the ``GET`` request against.
:returns: the deserialized response body.
:raises:
:exc:`RetriesExceeded`: raised from the corresponding :mod:`urllib3`
exception.
"""
url = self._build_full_url(url_path)
response = None
try:
response = self._http.request("GET", url)
except MaxRetryError as exc:
msg = f"Failed to perform a GET request to {url}"
raise RetriesExceeded(msg) from exc
return self._deserialize_response(response)
def _post_request(self, url_path: str, body: Any) -> Any:
"""Perform a ``POST`` request to *url_path* serializing *body* as JSON.
:param url_path: URL to perform the ``POST`` request against.
:param body: the body to be serialized as JSON and sent in the request.
:returns: the deserialized response body.
:raises:
:exc:`RetriesExceeded`: raised from the corresponding :mod:`urllib3`
exception.
"""
body = self._serialize_request(body)
url = self._build_full_url(url_path)
response = None
try:
response = self._http.request("POST",
url,
body=body,
headers={
"Content-Type": "application/json"
})
except MaxRetryError as exc:
msg = f"Failed to perform a POST request to {url} with {body}"
raise RetriesExceeded(msg) from exc
return self._deserialize_response(response)
def _ensure_api_ok(self) -> None:
"""Ensure ``pg-backup-api`` is reachable and ``OK``.
:raises:
:exc:`ApiNotOk`: if ``pg-backup-api`` status is not ``OK``.
"""
response = self._get_request("status")
if response != "OK":
msg = (
"pg-backup-api is currently not up and running at "
f"{self.api_url}: {response}"
)
raise ApiNotOk(msg)
@retry(KeyError)
def get_operation_status(self, barman_server: str,
operation_id: str) -> OperationStatus:
"""Get status of the operation which ID is *operation_id*.
:param barman_server: name of the Barman server related with the
operation.
:param operation_id: ID of the operation to be checked.
:returns: the status of the operation.
"""
response = self._get_request(
f"servers/{barman_server}/operations/{operation_id}",
)
status = response["status"]
return OperationStatus[status]
@retry(KeyError)
def create_recovery_operation(self, barman_server: str, backup_id: str,
ssh_command: str, data_directory: str) -> str:
"""Create a recovery operation on the ``pg-backup-api``.
:param barman_server: name of the Barman server which backup is to be
restored.
:param backup_id: ID of the backup from the Barman server.
:param ssh_command: SSH command to connect from the Barman host to the
target host.
:param data_directory: path to the Postgres data directory where to
restore the backup at.
:returns: the ID of the recovery operation that has been created.
"""
response = self._post_request(
f"servers/{barman_server}/operations",
{
"type": "recovery",
"backup_id": backup_id,
"remote_ssh_command": ssh_command,
"destination_directory": data_directory,
},
)
return response["operation_id"]
@retry(KeyError)
def create_config_switch_operation(self, barman_server: str,
barman_model: Optional[str],
reset: Optional[bool]) -> str:
"""Create a config switch operation on the ``pg-backup-api``.
:param barman_server: name of the Barman server which config is to be
switched.
:param barman_model: name of the Barman model to be applied to the
server, if any.
:param reset: ``True`` if you would like to unapply the currently active
model for the server, if any.
:returns: the ID of the config switch operation that has been created.
"""
body: Dict[str, Any] = {"type": "config_switch"}
if barman_model:
body["model_name"] = barman_model
elif reset:
body["reset"] = reset
response = self._post_request(
f"servers/{barman_server}/operations",
body,
)
return response["operation_id"]
+99
View File
@@ -0,0 +1,99 @@
"""Tags handling."""
import abc
from typing import Any, Dict, Optional
from patroni.utils import parse_int, parse_bool
class Tags(abc.ABC):
"""An abstract class that encapsulates all the ``tags`` logic.
Child classes that want to use provided facilities must implement ``tags`` abstract property.
.. note::
Due to backward-compatibility reasons, old tags may have a less strict type conversion than new ones.
"""
@staticmethod
def _filter_tags(tags: Dict[str, Any]) -> Dict[str, Any]:
"""Get tags configured for this node, if any.
Handle both predefined Patroni tags and custom defined tags.
.. note::
A custom tag is any tag added to the configuration ``tags`` section that is not one of ``clonefrom``,
``nofailover``, ``noloadbalance``,``nosync`` or ``nostream``.
For most of the Patroni predefined tags, the returning object will only contain them if they are enabled as
they all are boolean values that default to disabled.
However ``nofailover`` tag is always returned if ``failover_priority`` tag is defined. In this case, we need
both values to see if they are contradictory and the ``nofailover`` value should be used.
:returns: a dictionary of tags set for this node. The key is the tag name, and the value is the corresponding
tag value.
"""
return {tag: value for tag, value in tags.items()
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync', 'nostream'),
value,
tag == 'nofailover' and 'failover_priority' in tags))}
@property
@abc.abstractmethod
def tags(self) -> Dict[str, Any]:
"""Configured tags.
Must be implemented in a child class.
"""
raise NotImplementedError # pragma: no cover
@property
def clonefrom(self) -> bool:
"""``True`` if ``clonefrom`` tag is ``True``, else ``False``."""
return self.tags.get('clonefrom', False)
@property
def nofailover(self) -> bool:
"""Common logic for obtaining the value of ``nofailover`` from ``tags`` if defined.
If ``nofailover`` is not defined, this methods returns ``True`` if ``failover_priority`` is non-positive,
``False`` otherwise.
"""
from_tags = self.tags.get('nofailover')
if from_tags is not None:
# Value of `nofailover` takes precedence over `failover_priority`
return bool(from_tags)
failover_priority = parse_int(self.tags.get('failover_priority'))
return failover_priority is not None and failover_priority <= 0
@property
def failover_priority(self) -> int:
"""Common logic for obtaining the value of ``failover_priority`` from ``tags`` if defined.
If ``nofailover`` is defined as ``True``, this will return ``0``. Otherwise, it will return the value of
``failover_priority``, defaulting to ``1`` if it's not defined or invalid.
"""
from_tags = self.tags.get('nofailover')
failover_priority = parse_int(self.tags.get('failover_priority'))
failover_priority = 1 if failover_priority is None else failover_priority
return 0 if from_tags else failover_priority
@property
def noloadbalance(self) -> bool:
"""``True`` if ``noloadbalance`` is ``True``, else ``False``."""
return bool(self.tags.get('noloadbalance', False))
@property
def nosync(self) -> bool:
"""``True`` if ``nosync`` is ``True``, else ``False``."""
return bool(self.tags.get('nosync', False))
@property
def replicatefrom(self) -> Optional[str]:
"""Value of ``replicatefrom`` tag, if any."""
return self.tags.get('replicatefrom')
@property
def nostream(self) -> bool:
"""``True`` if ``nostream`` is ``True``, else ``False``."""
return parse_bool(self.tags.get('nostream')) or False
+242 -47
View File
@@ -10,12 +10,14 @@
:var WHITESPACE_RE: regular expression to match whitespace characters
"""
import errno
import itertools
import logging
import os
import platform
import random
import re
import socket
import subprocess
import sys
import tempfile
import time
@@ -23,6 +25,7 @@ from shlex import split
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from collections import OrderedDict
from dateutil import tz
from json import JSONDecoder
from urllib3.response import HTTPResponse
@@ -32,7 +35,6 @@ from .version import __version__
if TYPE_CHECKING: # pragma: no cover
from .dcs import Cluster
from .config import GlobalConfig
tzutc = tz.tzutc()
@@ -46,6 +48,37 @@ DBL_RE = re.compile(r'^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?')
WHITESPACE_RE = re.compile(r'[ \t\n\r]*', re.VERBOSE | re.MULTILINE | re.DOTALL)
def get_conversion_table(base_unit: str) -> Dict[str, Dict[str, Union[int, float]]]:
"""Get conversion table for the specified base unit.
If no conversion table exists for the passed unit, return an empty :class:`OrderedDict`.
:param base_unit: unit to choose the conversion table for.
:returns: :class:`OrderedDict` object.
"""
memory_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
('TB', {'B': 1024**4, 'kB': 1024**3, 'MB': 1024**2}),
('GB', {'B': 1024**3, 'kB': 1024**2, 'MB': 1024}),
('MB', {'B': 1024**2, 'kB': 1024, 'MB': 1}),
('kB', {'B': 1024, 'kB': 1, 'MB': 1024**-1}),
('B', {'B': 1, 'kB': 1024**-1, 'MB': 1024**-2})
])
time_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
('d', {'ms': 1000 * 60**2 * 24, 's': 60**2 * 24, 'min': 60 * 24}),
('h', {'ms': 1000 * 60**2, 's': 60**2, 'min': 60}),
('min', {'ms': 1000 * 60, 's': 60, 'min': 1}),
('s', {'ms': 1000, 's': 1, 'min': 60**-1}),
('ms', {'ms': 1, 's': 1000**-1, 'min': 1 / (1000 * 60)}),
('us', {'ms': 1000**-1, 's': 1000**-2, 'min': 1 / (1000**2 * 60)})
])
if base_unit in ('B', 'kB', 'MB'):
return memory_unit_conversion_table
elif base_unit in ('ms', 's', 'min'):
return time_unit_conversion_table
return OrderedDict()
def deep_compare(obj1: Dict[Any, Union[Any, Dict[Any, Any]]], obj2: Dict[Any, Union[Any, Dict[Any, Any]]]) -> bool:
"""Recursively compare two dictionaries to check if they are equal in terms of keys and values.
@@ -272,35 +305,154 @@ def convert_to_base_unit(value: Union[int, float], unit: str, base_unit: Optiona
>>> convert_to_base_unit(1, 'GB', '512 MB') is None
True
"""
convert: Dict[str, Dict[str, Union[int, float]]] = {
'B': {'B': 1, 'kB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024},
'kB': {'B': 1.0 / 1024, 'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
'MB': {'B': 1.0 / (1024 * 1024), 'kB': 1.0 / 1024, 'MB': 1, 'GB': 1024, 'TB': 1024 * 1024},
'ms': {'us': 1.0 / 1000, 'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
's': {'us': 1.0 / (1000 * 1000), 'ms': 1.0 / 1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
'min': {'us': 1.0 / (1000 * 1000 * 60), 'ms': 1.0 / (1000 * 60), 's': 1.0 / 60, 'min': 1, 'h': 60, 'd': 60 * 24}
}
round_order = {
'TB': 'GB', 'GB': 'MB', 'MB': 'kB', 'kB': 'B',
'd': 'h', 'h': 'min', 'min': 's', 's': 'ms', 'ms': 'us'
}
if base_unit and base_unit not in convert:
base_value, base_unit = strtol(base_unit, False)
else:
base_value = 1
if base_value is not None and base_unit in convert and unit in convert[base_unit]:
value *= convert[base_unit][unit] / float(base_value)
base_value, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value, int)
convert_tbl = get_conversion_table(base_unit)
# {'TB': 'GB', 'GB': 'MB', ...}
round_order = dict(zip(convert_tbl, itertools.islice(convert_tbl, 1, None)))
if unit in convert_tbl and base_unit in convert_tbl[unit]:
value *= convert_tbl[unit][base_unit] / float(base_value)
if unit in round_order:
multiplier = convert[base_unit][round_order[unit]]
multiplier = convert_tbl[round_order[unit]][base_unit]
value = round(value / float(multiplier)) * multiplier
return value
def convert_int_from_base_unit(base_value: int, base_unit: Optional[str]) -> Optional[str]:
"""Convert an integer value in some base unit to a human-friendly unit.
The output unit is chosen so that it's the greatest unit that can represent
the value without loss.
:param base_value: value to be converted from a base unit
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
* For space: ``B``, ``kB``, ``MB``;
* For time: ``ms``, ``s``, ``min``.
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
possible human-friendly unit, or ``None`` if conversion failed.
:Example:
>>> convert_int_from_base_unit(1024, 'kB')
'1MB'
>>> convert_int_from_base_unit(1025, 'kB')
'1025kB'
>>> convert_int_from_base_unit(4, '256MB')
'1GB'
>>> convert_int_from_base_unit(4, '256 MB') is None
True
>>> convert_int_from_base_unit(1024, 'KB') is None
True
"""
base_value_mult, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value_mult, int)
base_value *= base_value_mult
convert_tbl = get_conversion_table(base_unit)
for unit in convert_tbl:
multiplier = convert_tbl[unit][base_unit]
if multiplier <= 1.0 or base_value % multiplier == 0:
return str(round(base_value / multiplier)) + unit
def convert_real_from_base_unit(base_value: float, base_unit: Optional[str]) -> Optional[str]:
"""Convert an floating-point value in some base unit to a human-friendly unit.
Same as :func:`convert_int_from_base_unit`, except we have to do the math a bit differently,
and there's a possibility that we don't find any exact divisor.
:param base_value: value to be converted from a base unit
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
* For space: ``B``, ``kB``, ``MB``;
* For time: ``ms``, ``s``, ``min``.
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
possible human-friendly unit, or ``None`` if conversion failed.
:Example:
>>> convert_real_from_base_unit(5, 'ms')
'5ms'
>>> convert_real_from_base_unit(2.5, 'ms')
'2500us'
>>> convert_real_from_base_unit(4.0, '256MB')
'1GB'
>>> convert_real_from_base_unit(4.0, '256 MB') is None
True
"""
base_value_mult, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value_mult, int)
base_value *= base_value_mult
result = None
convert_tbl = get_conversion_table(base_unit)
for unit in convert_tbl:
value = base_value / convert_tbl[unit][base_unit]
result = f'{value:g}{unit}'
if value > 0 and abs((round(value) / value) - 1.0) <= 1e-8:
break
return result
def maybe_convert_from_base_unit(base_value: str, vartype: str, base_unit: Optional[str]) -> str:
"""Try to convert integer or real value in a base unit to a human-readable unit.
Value is passed as a string. If parsing or subsequent conversion fails, the original
value is returned.
:param base_value: value to be converted from a base unit.
:param vartype: the target type to parse *base_value* before converting (``integer``
or ``real`` is expected, any other type results in return value being equal to the
*base_value* string).
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
* For space: ``B``, ``kB``, ``MB``;
* For time: ``ms``, ``s``, ``min``.
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
possible human-friendly unit, or *base_value* string if conversion failed.
:Example:
>>> maybe_convert_from_base_unit('5', 'integer', 'ms')
'5ms'
>>> maybe_convert_from_base_unit('4.2', 'real', 'ms')
'4200us'
>>> maybe_convert_from_base_unit('on', 'bool', None)
'on'
>>> maybe_convert_from_base_unit('', 'integer', '256MB')
''
"""
converters: Dict[str, Tuple[Callable[[str, Optional[str]], Union[int, float, str, None]],
Callable[[Any, Optional[str]], Optional[str]]]] = {
'integer': (parse_int, convert_int_from_base_unit),
'real': (parse_real, convert_real_from_base_unit),
'default': (lambda v, _: v, lambda v, _: v)
}
parser, converter = converters.get(vartype, converters['default'])
parsed_value = parser(base_value, None)
if parsed_value:
return converter(parsed_value, base_unit) or base_value
return base_value
def parse_int(value: Any, base_unit: Optional[str] = None) -> Optional[int]:
"""Parse *value* as an :class:`int`.
@@ -400,22 +552,23 @@ def parse_real(value: Any, base_unit: Optional[str] = None) -> Optional[float]:
return convert_to_base_unit(val, unit, base_unit)
def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value: Any) -> bool:
"""Check if *old_value* and *new_value* are equivalent after parsing them as *vartype*.
def compare_values(vartype: str, unit: Optional[str], settings_value: Any, config_value: Any) -> bool:
"""Check if the value from ``pg_settings`` and from Patroni config are equivalent after parsing them as *vartype*.
:param vartpe: the target type to parse *old_value* and *new_value* before comparing them. Accepts any among of the
following (case sensitive):
:param vartype: the target type to parse *settings_value* and *config_value* before comparing them.
Accepts any among of the following (case sensitive):
* ``bool``: parse values using :func:`parse_bool`; or
* ``integer``: parse values using :func:`parse_int`; or
* ``real``: parse values using :func:`parse_real`; or
* ``enum``: parse values as lowercase strings; or
* ``string``: parse values as strings. This one is used by default if no valid value is passed as *vartype*.
:param unit: base unit to be used as argument when calling :func:`parse_int` or :func:`parse_real` for *new_value*.
:param old_value: value to be compared with *new_value*.
:param new_value: value to be compared with *old_value*.
:param unit: base unit to be used as argument when calling :func:`parse_int` or :func:`parse_real`
for *config_value*.
:param settings_value: value to be compared with *config_value*.
:param config_value: value to be compared with *settings_value*.
:returns: ``True`` if *old_value* is equivalent to *new_value* when both are parsed as *vartype*.
:returns: ``True`` if *settings_value* is equivalent to *config_value* when both are parsed as *vartype*.
:Example:
@@ -455,8 +608,8 @@ def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value:
}
converter = converters.get(vartype) or converters['string']
old_converted = converter(old_value, None)
new_converted = converter(new_value, unit)
old_converted = converter(settings_value, None)
new_converted = converter(config_value, unit)
return old_converted is not None and new_converted is not None and old_converted == new_converted
@@ -470,6 +623,18 @@ def _sleep(interval: Union[int, float]) -> None:
time.sleep(interval)
def read_stripped(file_path: str) -> Iterator[str]:
"""Iterate over stripped lines in the given file.
:param file_path: path to the file to read from
:yields: each line from the given file stripped
"""
with open(file_path) as f:
for line in f:
yield line.strip()
class RetryFailedError(PatroniException):
"""Maximum number of attempts exhausted in retry operation."""
@@ -551,7 +716,7 @@ class Retry(object):
return self._cur_stoptime or 0
def ensure_deadline(self, timeout: float, raise_ex: Optional[Exception] = None) -> bool:
"""Calculates, sets, and checks the remaining deadline time.
"""Calculates and checks the remaining deadline time.
:param timeout: if the *deadline* is smaller than the provided *timeout* value raise *raise_ex* exception.
:param raise_ex: the exception object that will be raised if the *deadline* is smaller than provided *timeout*.
@@ -562,8 +727,7 @@ class Retry(object):
:raises:
:class:`Exception`: *raise_ex* if calculated deadline is smaller than provided *timeout*.
"""
self.deadline = self.stoptime - time.time()
if self.deadline < timeout:
if self.stoptime - time.time() < timeout:
if raise_ex:
raise raise_ex
return False
@@ -746,12 +910,10 @@ def iter_response_objects(response: HTTPResponse) -> Iterator[Dict[str, Any]]:
prev = chunk[idx:]
def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] = None) -> Dict[str, Any]:
def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
"""Get a JSON representation of *cluster*.
:param cluster: the :class:`~patroni.dcs.Cluster` object to be parsed as JSON.
:param global_config: optional :class:`~patroni.config.GlobalConfig` object to check the cluster state.
if not provided will be instantiated from the `Cluster.config`.
:returns: JSON representation of *cluster*.
@@ -780,16 +942,16 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig']
* ``from``: name of the member to be demoted;
* ``to``: name of the member to be promoted.
"""
if not global_config:
from patroni.config import get_global_config
global_config = get_global_config(cluster)
from . import global_config
config = global_config.from_cluster(cluster)
leader_name = cluster.leader.name if cluster.leader else None
cluster_lsn = cluster.last_lsn or 0
ret: Dict[str, Any] = {'members': []}
for m in cluster.members:
if m.name == leader_name:
role = 'standby_leader' if global_config.is_standby_cluster else 'leader'
role = 'standby_leader' if config.is_standby_cluster else 'leader'
elif cluster.sync.matches(m.name):
role = 'sync_standby'
else:
@@ -802,11 +964,11 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig']
member['host'] = conn_kwargs['host']
if conn_kwargs.get('port'):
member['port'] = int(conn_kwargs['port'])
optional_attributes = ('timeline', 'pending_restart', 'scheduled_restart', 'tags')
optional_attributes = ('timeline', 'pending_restart', 'pending_restart_reason', 'scheduled_restart', 'tags')
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
if m.name != leader_name:
lsn = m.data.get('xlog_location')
lsn = m.lsn
if lsn is None:
member['lag'] = 'unknown'
elif cluster_lsn >= lsn:
@@ -819,7 +981,7 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig']
# sort members by name for consistency
cmp: Callable[[Dict[str, Any]], bool] = lambda m: m['name']
ret['members'].sort(key=cmp)
if global_config.is_paused:
if config.is_paused:
ret['pause'] = True
if cluster.failover and cluster.failover.scheduled_at:
ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()}
@@ -1015,3 +1177,36 @@ def unquote(string: str) -> str:
except ValueError:
ret = string
return ret
def get_major_version(bin_dir: Optional[str] = None, bin_name: str = 'postgres') -> str:
"""Get the major version of PostgreSQL.
It is based on the output of ``postgres --version``.
:param bin_dir: path to the PostgreSQL binaries directory. If ``None`` or an empty string, it will use the first
*bin_name* binary that is found by the subprocess in the ``PATH``.
:param bin_name: name of the postgres binary to call (``postgres`` by default)
:returns: the PostgreSQL major version.
:raises:
:exc:`~patroni.exceptions.PatroniException`: if the postgres binary call failed due to :exc:`OSError`.
:Example:
* Returns `9.6` for PostgreSQL 9.6.24
* Returns `15` for PostgreSQL 15.2
"""
if not bin_dir:
binary = bin_name
else:
binary = os.path.join(bin_dir, bin_name)
try:
version = subprocess.check_output([binary, '--version']).decode()
except OSError as e:
raise PatroniException(f'Failed to get postgres version: {e}')
version = re.match(r'^[^\s]+ [^\s]+ (\d+)(\.(\d+))?', version)
if TYPE_CHECKING: # pragma: no cover
assert version is not None
return '.'.join([version.group(1), version.group(3)]) if int(version.group(1)) < 10 else version.group(1)
+207 -115
View File
@@ -6,17 +6,58 @@ This module contains facilities for validating configuration of Patroni processe
:var schema: configuration schema of the daemon launched by ``patroni`` command.
"""
import os
import re
import shutil
import socket
import subprocess
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING
from .collections import CaseInsensitiveSet
from .collections import CaseInsensitiveSet, EMPTY_DICT
from .dcs import dcs_modules
from .exceptions import ConfigParseError
from .utils import parse_int, split_host_port, data_directory_is_empty
from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version
from .log import type_logformat
def validate_log_field(field: Union[str, Dict[str, Any], Any]) -> bool:
"""Checks if log field is valid.
:param field: A log field to be validated.
:returns: ``True`` if the field is either a string or a dictionary with exactly one key
that has string value, ``False`` otherwise.
"""
if isinstance(field, str):
return True
elif isinstance(field, dict):
return len(field) == 1 and isinstance(next(iter(field.values())), str)
return False
def validate_log_format(logformat: type_logformat) -> bool:
"""Checks if log format is valid.
:param logformat: A log format to be validated.
:returns: ``True`` if the log format is either a string or a list of valid log fields.
:raises:
:exc:`~patroni.exceptions.ConfigParseError`:
* If the logformat is not a string or a list; or
* If the logformat is an empty list; or
* If the log format is a list and it with values that don't pass validation using
:func:`validate_log_field`.
"""
if isinstance(logformat, str):
return True
elif isinstance(logformat, list):
if len(logformat) == 0:
raise ConfigParseError('should contain at least one item')
if not all(map(validate_log_field, logformat)):
raise ConfigParseError('each item should be a string or a dictionary with string values')
return True
else:
raise ConfigParseError('Should be a string or a list')
def data_directory_empty(data_dir: str) -> bool:
@@ -201,32 +242,9 @@ def get_bin_name(bin_name: str) -> str:
:returns: value of ``postgresql.bin_name[*bin_name*]``, if present, otherwise *bin_name*.
"""
return (schema.data.get('postgresql', {}).get('bin_name', {}) or {}).get(bin_name, bin_name)
def get_major_version(bin_dir: OptionalType[str] = None) -> str:
"""Get the major version of PostgreSQL.
It is based on the output of ``postgres --version``.
:param bin_dir: path to PostgreSQL binaries directory. If ``None`` it will use the first ``postgres`` binary that
is found by subprocess in the ``PATH``.
:returns: the PostgreSQL major version.
:Example:
* Returns `9.6` for PostgreSQL 9.6.24
* Returns `15` for PostgreSQL 15.2
"""
if not bin_dir:
binary = get_bin_name('postgres')
else:
binary = os.path.join(bin_dir, get_bin_name('postgres'))
version = subprocess.check_output([binary, '--version']).decode()
version = re.match(r'^[^\s]+ [^\s]+ (\d+)(\.(\d+))?', version)
if TYPE_CHECKING: # pragma: no cover
assert version is not None
return '.'.join([version.group(1), version.group(3)]) if int(version.group(1)) < 10 else version.group(1)
assert isinstance(schema.data, dict)
return (schema.data.get('postgresql', {}).get('bin_name', {}) or EMPTY_DICT).get(bin_name, bin_name)
def validate_data_dir(data_dir: str) -> bool:
@@ -265,8 +283,10 @@ def validate_data_dir(data_dir: str) -> bool:
if not os.path.isdir(os.path.join(data_dir, waldir)):
raise ConfigParseError("data dir for the cluster is not empty, but doesn't contain"
" \"{}\" directory".format(waldir))
if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict)
bin_dir = schema.data.get("postgresql", {}).get("bin_dir", None)
major_version = get_major_version(bin_dir)
major_version = get_major_version(bin_dir, get_bin_name('postgres'))
if pgversion != major_version:
raise ConfigParseError("data_dir directory postgresql version ({}) doesn't match with "
"'postgres --version' output ({})".format(pgversion, major_version))
@@ -300,6 +320,8 @@ def validate_binary_name(bin_name: str) -> bool:
"""
if not bin_name:
raise ConfigParseError("is an empty string")
if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict)
bin_dir = schema.data.get('postgresql', {}).get('bin_dir', None)
if not shutil.which(bin_name, path=bin_dir):
raise ConfigParseError(f"does not contain '{bin_name}' in '{bin_dir or '$PATH'}'")
@@ -359,15 +381,17 @@ class Case(object):
"""Create a :class:`Case` object.
:param schema: the schema for validating a set of attributes that may be available in the configuration.
Each key is the configuration that is available in a given scope and that should be validated, and the
related value is the validation function or expected type.
Each key is the configuration that is available in a given scope and that should be validated,
and the related value is the validation function or expected type.
:Example:
Case({
"host": validate_host_port,
"url": str,
})
.. code-block:: python
Case({
"host": validate_host_port,
"url": str,
})
That will check that ``host`` configuration, if given, is valid based on :func:`validate_host_port`, and will
also check that ``url`` configuration, if given, is a ``str`` instance.
@@ -389,14 +413,47 @@ class Or(object):
:Example:
Or("host", "hosts"): Case({
"host": validate_host_port,
"hosts": Or(comma_separated_host_port, [validate_host_port]),
})
.. code-block:: python
The outer :class:`Or` is used to define that ``host`` and ``hosts`` are possible options in this scope.
The inner :class`Or` in the ``hosts`` key value is used to define that ``hosts`` option is valid if either of
:func:`comma_separated_host_port` or :func:`validate_host_port` succeed to validate it.
Or("host", "hosts"): Case({
"host": validate_host_port,
"hosts": Or(comma_separated_host_port, [validate_host_port]),
})
The outer :class:`Or` is used to define that ``host`` and ``hosts`` are possible options in this scope.
The inner :class`Or` in the ``hosts`` key value is used to define that ``hosts`` option is valid if either
of :func:`comma_separated_host_port` or :func:`validate_host_port` succeed to validate it.
"""
self.args = args
class AtMostOne(object):
"""Mark that at most one option from a :class:`Case` can be suplied.
Represents a list of possible configuration options in a given scope, where at most one can actually
be provided.
.. note::
It should be used together with a :class:`Case` object.
"""
def __init__(self, *args: str) -> None:
"""Create a :class`AtMostOne` object.
:param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object.
:Example:
.. code-block:: python
AtMostOne("nofailover", "failover_priority"): Case({
"nofailover": bool,
"failover_priority": IntValidator(min=0, raise_assert=True),
})
The :class`AtMostOne` object is used to define that at most one of ``nofailover`` and
``failover_priority`` can be provided.
"""
self.args = args
@@ -514,7 +571,7 @@ class Schema(object):
* :class:`dict`: dictionary representing the YAML configuration tree.
"""
def __init__(self, validator: Any) -> None:
def __init__(self, validator: Union[Dict[Any, Any], List[Any], Any]) -> None:
"""Create a :class:`Schema` object.
.. note::
@@ -561,32 +618,34 @@ class Schema(object):
:Example:
Schema({
"application_name": str,
"bind": {
"host": validate_host,
"port": int,
},
"aliases": [str],
Optional("data_directory"): "/var/lib/myapp",
Or("log_to_file", "log_to_db"): Case({
"log_to_file": bool,
"log_to_db": bool,
}),
"version": Or(int, float),
})
.. code-block:: python
This sample schema defines that your YAML configuration follows these rules:
Schema({
"application_name": str,
"bind": {
"host": validate_host,
"port": int,
},
"aliases": [str],
Optional("data_directory"): "/var/lib/myapp",
Or("log_to_file", "log_to_db"): Case({
"log_to_file": bool,
"log_to_db": bool,
}),
"version": Or(int, float),
})
* It must contain an ``application_name`` entry which value should be a :class:`str` instance;
* It must contain a ``bind.host`` entry which value should be valid as per function ``validate_host``;
* It must contain a ``bind.port`` entry which value should be an :class:`int` instance;
* It must contain a ``aliases`` entry which value should be a :class:`list` of :class:`str` instances;
* It may optionally contain a ``data_directory`` entry, with a value which should be a string;
* It must contain at least one of ``log_to_file`` or ``log_to_db``, with a value which should be a
:class:`bool` instance;
* It must contain a ``version`` entry which value should be either an :class:`int` or a :class:`float`
instance.
This sample schema defines that your YAML configuration follows these rules:
* It must contain an ``application_name`` entry which value should be a :class:`str` instance;
* It must contain a ``bind.host`` entry which value should be valid as per function ``validate_host``;
* It must contain a ``bind.port`` entry which value should be an :class:`int` instance;
* It must contain a ``aliases`` entry which value should be a :class:`list` of :class:`str` instances;
* It may optionally contain a ``data_directory`` entry, with a value which should be a string;
* It must contain at least one of ``log_to_file`` or ``log_to_db``, with a value which should be a
:class:`bool` instance;
* It must contain a ``version`` entry which value should be either an :class:`int` or a :class:`float`
instance.
"""
self.validator = validator
@@ -603,7 +662,7 @@ class Schema(object):
errors.append(str(i))
return errors
def validate(self, data: Any) -> Iterator[Result]:
def validate(self, data: Union[Dict[Any, Any], Any]) -> Iterator[Result]:
"""Perform all validations from the schema against the given configuration.
It first checks that *data* argument type is compliant with the type of ``validator`` attribute.
@@ -627,11 +686,8 @@ class Schema(object):
# iterable objects in the structure, until we eventually reach a leaf node to validate its value.
if isinstance(self.validator, str):
yield Result(isinstance(self.data, str), "is not a string", level=1, data=self.data)
elif issubclass(type(self.validator), type):
validator = self.validator
if self.validator == str:
validator = str
yield Result(isinstance(self.data, validator),
elif isinstance(self.validator, type):
yield Result(isinstance(self.data, self.validator),
"is not {}".format(_get_type_name(self.validator)), level=1, data=self.data)
elif callable(self.validator):
if hasattr(self.validator, "expected_type"):
@@ -678,7 +734,7 @@ class Schema(object):
for v in Schema(self.validator[0]).validate(value):
yield Result(v.status, v.error,
path=(str(key) + ("." + v.path if v.path else "")), level=v.level, data=value)
elif isinstance(self.validator, Directory):
elif isinstance(self.validator, Directory) and isinstance(self.data, str):
yield from self.validator.validate(self.data)
elif isinstance(self.validator, Or):
yield from self.iter_or()
@@ -690,7 +746,13 @@ class Schema(object):
"""
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
# variable), depending on the `key` type.
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self.validator, dict)
assert isinstance(self.data, dict)
for key in self.validator.keys():
if isinstance(key, AtMostOne) and len(list(self._data_key(key))) > 1:
yield Result(False, f"Multiple of {key.args} provided")
continue
for d in self._data_key(key):
if d not in self.data and not isinstance(key, Optional):
yield Result(False, "is not defined.", path=d)
@@ -700,7 +762,7 @@ class Schema(object):
if d not in self.data and isinstance(key, Optional):
self.data[d] = key.default
validator = self.validator[key]
if isinstance(key, Or) and isinstance(self.validator[key], Case):
if isinstance(key, (Or, AtMostOne)) and isinstance(self.validator[key], Case):
validator = self.validator[key]._schema[d]
# In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or
# over a leaf node. In the latter case the recursive calls in the given path will finish.
@@ -716,6 +778,8 @@ class Schema(object):
:yields: objects with the error message related to the failure, if any check fails.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self.validator, Or)
results: List[Result] = []
for a in self.validator.args:
r: List[Result] = []
@@ -735,7 +799,7 @@ class Schema(object):
max_level = v.level
yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data)
def _data_key(self, key: Union[str, Optional, Or]) -> Iterator[str]:
def _data_key(self, key: Union[str, Optional, Or, AtMostOne]) -> Iterator[str]:
"""Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary.
:param key: key from the ``validator`` attribute.
@@ -752,18 +816,26 @@ class Schema(object):
yield key.name
# If the key was defined as an `Or` object in `validator` attribute, then each of its values are the keys to
# access the `data` dictionary.
elif isinstance(key, Or):
elif isinstance(key, Or) and isinstance(self.data, dict):
# At least one of the `Or` entries should be available in the `data` dictionary. If we find at least one of
# them in `data`, then we return all found entries so the caller method can validate them all.
if any([i in self.data for i in key.args]):
for i in key.args:
if i in self.data:
yield i
if any([item in self.data for item in key.args]):
for item in key.args:
if item in self.data:
yield item
# If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the
# caller method will issue errors that they are all absent.
else:
for i in key.args:
yield i
for item in key.args:
yield item
# If the key was defined as a `AtMostOne` object in `validator` attribute, then each of its values
# are the keys to access the `data` dictionary.
elif isinstance(key, AtMostOne) and isinstance(self.data, dict):
# Yield back all of the entries from the `data` dictionary, each will be validated and then counted
# to inform us if we've provided too many
for item in key.args:
if item in self.data:
yield item
def _get_type_name(python_type: Any) -> str:
@@ -792,27 +864,28 @@ def assert_(condition: bool, message: str = "Wrong value") -> None:
class IntValidator(object):
"""Validate an integer setting.
:cvar expected_type: the expected Python type for an integer setting (:class:`int`).
:ivar min: minimum allowed value for the setting, if any.
:ivar max: maximum allowed value for the setting, if any.
:ivar base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
:ivar expected_type: the expected Python type.
:ivar raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
"""
expected_type = int
def __init__(self, min: OptionalType[int] = None, max: OptionalType[int] = None,
base_unit: OptionalType[str] = None, raise_assert: bool = False) -> None:
base_unit: OptionalType[str] = None, expected_type: Any = None, raise_assert: bool = False) -> None:
"""Create an :class:`IntValidator` object with the given rules.
:param min: minimum allowed value for the setting, if any.
:param max: maximum allowed value for the setting, if any.
:param base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
:param expected_type: the expected Python type.
:param raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
"""
self.min = min
self.max = max
self.base_unit = base_unit
if expected_type:
self.expected_type = expected_type
self.raise_assert = raise_assert
def __call__(self, value: Any) -> bool:
@@ -906,6 +979,20 @@ validate_etcd = {
schema = Schema({
"name": str,
"scope": str,
Optional("log"): {
Optional("type"): EnumValidator(('plain', 'json'), case_sensitive=True, raise_assert=True),
Optional("level"): EnumValidator(('DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL', 'CRITICAL'),
case_sensitive=True, raise_assert=True),
Optional("traceback_level"): EnumValidator(('DEBUG', 'ERROR'), raise_assert=True),
Optional("format"): validate_log_format,
Optional("dateformat"): str,
Optional("static_fields"): dict,
Optional("max_queue_size"): int,
Optional("dir"): str,
Optional("file_num"): int,
Optional("file_size"): int,
Optional("loggers"): dict
},
Optional("ctl"): {
Optional("insecure"): bool,
Optional("cacert"): str,
@@ -931,36 +1018,36 @@ schema = Schema({
Optional("allowlist_include_members"): bool,
Optional("http_extra_headers"): dict,
Optional("https_extra_headers"): dict,
Optional("request_queue_size"): IntValidator(min=0, max=4096, raise_assert=True)
Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True)
},
Optional("bootstrap"): {
"dcs": {
Optional("ttl"): int,
Optional("loop_wait"): int,
Optional("retry_timeout"): int,
Optional("maximum_lag_on_failover"): int,
Optional("maximum_lag_on_syncnode"): int,
Optional("ttl"): IntValidator(min=20, raise_assert=True),
Optional("loop_wait"): IntValidator(min=1, raise_assert=True),
Optional("retry_timeout"): IntValidator(min=3, raise_assert=True),
Optional("maximum_lag_on_failover"): IntValidator(min=0, raise_assert=True),
Optional("maximum_lag_on_syncnode"): IntValidator(min=-1, raise_assert=True),
Optional("postgresql"): {
Optional("parameters"): {
Optional("max_connections"): int,
Optional("max_locks_per_transaction"): int,
Optional("max_prepared_transactions"): int,
Optional("max_replication_slots"): int,
Optional("max_wal_senders"): int,
Optional("max_worker_processes"): int
Optional("max_connections"): IntValidator(1, 262143, raise_assert=True),
Optional("max_locks_per_transaction"): IntValidator(10, 2147483647, raise_assert=True),
Optional("max_prepared_transactions"): IntValidator(0, 262143, raise_assert=True),
Optional("max_replication_slots"): IntValidator(0, 262143, raise_assert=True),
Optional("max_wal_senders"): IntValidator(0, 262143, raise_assert=True),
Optional("max_worker_processes"): IntValidator(0, 262143, raise_assert=True),
},
Optional("use_pg_rewind"): bool,
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): int,
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
Optional("use_slots"): bool,
},
Optional("primary_start_timeout"): int,
Optional("primary_stop_timeout"): int,
Optional("primary_start_timeout"): IntValidator(min=0, raise_assert=True),
Optional("primary_stop_timeout"): IntValidator(min=0, raise_assert=True),
Optional("standby_cluster"): {
Or("host", "port", "restore_command"): Case({
"host": str,
"port": int,
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
"restore_command": str
}),
Optional("primary_slot_name"): str,
@@ -970,7 +1057,7 @@ schema = Schema({
},
Optional("synchronous_mode"): bool,
Optional("synchronous_mode_strict"): bool,
Optional("synchronous_node_count"): int
Optional("synchronous_node_count"): IntValidator(min=1, raise_assert=True),
},
Optional("initdb"): [Or(str, dict)],
Optional("method"): str
@@ -981,7 +1068,7 @@ schema = Schema({
"host": validate_host_port,
"url": str
}),
Optional("port"): int,
Optional("port"): IntValidator(max=65535, expected_type=int, raise_assert=True),
Optional("scheme"): str,
Optional("token"): str,
Optional("verify"): bool,
@@ -1001,8 +1088,8 @@ schema = Schema({
"etcd3": validate_etcd,
"exhibitor": {
"hosts": [str],
"port": IntValidator(max=65535, raise_assert=True),
Optional("pool_interval"): int
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
Optional("poll_interval"): IntValidator(min=1, expected_type=int, raise_assert=True),
},
"raft": {
"self_addr": validate_connect_address,
@@ -1019,7 +1106,8 @@ schema = Schema({
Optional("key"): str,
Optional("key_password"): str,
Optional("verify"): bool,
Optional("set_acls"): dict
Optional("set_acls"): dict,
Optional("auth_data"): dict,
},
"kubernetes": {
"labels": {},
@@ -1033,14 +1121,14 @@ schema = Schema({
Optional("tmp_role_label"): str,
Optional("use_endpoints"): bool,
Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address),
Optional("ports"): [{"name": str, "port": int}],
Optional("ports"): [{"name": str, "port": IntValidator(max=65535, expected_type=int, raise_assert=True)}],
Optional("cacert"): str,
Optional("retriable_http_codes"): Or(int, [int]),
},
}),
Optional("citus"): {
"database": str,
"group": int
"group": IntValidator(min=0, expected_type=int, raise_assert=True),
},
"postgresql": {
"listen": validate_host_port_listen_multiple_hosts,
@@ -1067,19 +1155,23 @@ schema = Schema({
},
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): int,
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
Optional("use_pg_rewind"): bool
},
Optional("watchdog"): {
Optional("mode"): validate_watchdog_mode,
Optional("device"): str,
Optional("safety_margin"): int
Optional("safety_margin"): IntValidator(min=-1, expected_type=int, raise_assert=True),
},
Optional("tags"): {
Optional("nofailover"): bool,
AtMostOne("nofailover", "failover_priority"): Case({
"nofailover": bool,
"failover_priority": IntValidator(min=0, expected_type=int, raise_assert=True),
}),
Optional("clonefrom"): bool,
Optional("noloadbalance"): bool,
Optional("replicatefrom"): str,
Optional("nosync"): bool
Optional("nosync"): bool,
Optional("nostream"): bool
}
})

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