Compare commits

...
259 Commits
Author SHA1 Message Date
48fbf64ea9 Release v3.3.0 (#3043)
* Make sure tests are not making external calls
and pass url with scheme to urllib3 to avoid warnings

* Make sure unit tests not rely on filesystem state

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

* Improve unit-tests code coverage

* Add release notes for 3.3.0

* Bump version

* Fix pyinstaller spec file

* python 3.6 compatibility

---------

Co-authored-by: Polina Bungina <[email protected]>
2024-04-04 17:51:26 +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 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
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 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 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
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 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 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 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
Jelte FennemaandGitHub 899cad1c0f Remove Python 2 install instructions from README (#2821) 2023-08-17 13:18:37 +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 KukushkinandGitHub 84aac437c1 Release v3.1.0 (#2801)
- bump pyright and resolve reported issues
- bump Patroni version
- update release notes
2023-08-03 13:02:29 +02:00
IsraelandGitHub 48e3d31e1d Refactor docs about migration to Patroni (#2796)
This PR is an attempt of refactoring the docs about migration to Patroni.

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

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

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

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

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

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

```
my_slot_name: the name of replication slot. If the permanent slot name
matches with the name of the current primary it will not be created.
Everything else is the responsibility of the operator to make sure that
there are no clashes in names between replication slots automatically
created by Patroni for members and permanent replication slots.
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Attemps to address issue #2735 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

References: PAT-107.

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

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

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

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

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

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

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

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

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

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

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

Besides that, everywhere in code start using `cluster.sync.matches()` instead of `name in cluster.sync.members`.
2023-03-28 07:36:45 +02:00
205 changed files with 28689 additions and 7603 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
If you have a question please post it on channel [#patroni](https://postgresteam.slack.com/archives/C9XPYG92A) in the [PostgreSQL Slack](https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA). If you have a question please post it on channel [#patroni](https://postgresteam.slack.com/archives/C9XPYG92A) in the [PostgreSQL Slack](https://pgtreats.info/slack-invite).
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**! Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
Please fill the form below and provide as much information as possible. Please fill the form below and provide as much information as possible.
Not doing so may result in your bug not being addressed in a timely manner. Not doing so may result in your bug not being addressed in a timely manner.
+1 -1
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Question - name: Question
url: https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA url: https://pgtreats.info/slack-invite
about: "Please ask questions on channel #patroni in the PostgreSQL Slack" about: "Please ask questions on channel #patroni in the PostgreSQL Slack"
+5 -5
View File
@@ -19,7 +19,7 @@ def install_requirements(what):
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave'] requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
requirements += ['coverage'] requirements += ['coverage']
# try to split tests between psycopg2 and psycopg3 # try to split tests between psycopg2 and psycopg3
requirements += ['psycopg[binary]'] if sys.version_info > (3, 7, 0) and\ requirements += ['psycopg[binary]'] if sys.version_info >= (3, 8, 0) and\
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary'] (sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
for r in read('requirements.txt').split('\n'): for r in read('requirements.txt').split('\n'):
r = r.strip() r = r.strip()
@@ -46,7 +46,7 @@ def install_packages(what):
packages = packages.get(what, []) packages = packages.get(what, [])
ver = versions.get(what) ver = versions.get(what)
if float(ver) >= 15: if float(ver) >= 15:
packages += ['postgresql-{0}-citus-11.2'.format(ver)] packages += ['postgresql-{0}-citus-12.1'.format(ver)]
subprocess.call(['sudo', 'apt-get', 'update', '-y']) subprocess.call(['sudo', 'apt-get', 'update', '-y'])
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages) return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
@@ -85,8 +85,8 @@ def unzip_all(archive):
def chmod_755(name): def chmod_755(name):
os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
def unpack(archive, name): def unpack(archive, name):
@@ -110,7 +110,7 @@ def install_etcd():
def install_postgres(): 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] platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
if platform == 'osx': if platform == 'osx':
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])]) return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
+1 -1
View File
@@ -1 +1 @@
versions = {'etcd': '9.6', 'etcd3': '14', '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 - name: Run tests and flake8
run: python .github/workflows/run_tests.py 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 - 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 - name: Publish distribution to Test PyPI
if: github.event_name == 'push' if: github.event_name == 'push'
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
unbuffer = ['timeout', '900', 'unbuffer'] unbuffer = ['timeout', '900', 'unbuffer']
else: else:
if sys.platform == 'darwin': 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]) path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
unbuffer = ['unbuffer'] unbuffer = ['unbuffer']
else: else:
+45 -3
View File
@@ -5,6 +5,7 @@ on:
push: push:
branches: branches:
- master - master
- 'REL_[0-9]+_[0-9]+'
env: env:
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
@@ -84,7 +85,7 @@ jobs:
env: env:
DCS: ${{ matrix.dcs }} DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.4.23 ETCDVERSION: 3.4.23
PGVERSION: 15.1-1 # for windows and macos PGVERSION: 16.1-1 # for windows and macos
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -116,8 +117,8 @@ jobs:
sudo apt-get install -y wget ca-certificates gnupg debian-archive-keyring apt-transport-https sudo apt-get install -y wget ca-certificates gnupg debian-archive-keyring apt-transport-https
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
sudo sh -c 'wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg' sudo sh -c 'wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg'
sudo sh -c 'echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://repos.citusdata.com/community/ubuntu/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list' sudo sh -c 'echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/ubuntu/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list'
sudo sh -c 'wget -qO - https://repos.citusdata.com/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg' sudo sh -c 'wget -qO - https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg'
if: matrix.os == 'ubuntu' if: matrix.os == 'ubuntu'
- name: Install dependencies - name: Install dependencies
run: python .github/workflows/install_deps.py run: python .github/workflows/install_deps.py
@@ -157,3 +158,44 @@ jobs:
steps: steps:
- run: bash <(curl -Ls https://coverage.codacy.com/get.sh) final - run: bash <(curl -Ls https://coverage.codacy.com/get.sh) final
if: ${{ env.SECRETS_AVAILABLE == 'true' }} if: ${{ env.SECRETS_AVAILABLE == 'true' }}
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Install dependencies
run: python -m pip install -r requirements.txt psycopg2-binary psycopg
- uses: jakebailey/pyright-action@v1
with:
version: 1.1.356
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11
cache: pip
- name: Install dependencies
run: pip install tox
- name: Install package dependencies
run: |
sudo apt update \
&& sudo apt install -y \
latexmk texlive-latex-extra tex-gyre \
--no-install-recommends
- name: Generate documentation
run: tox -m docs
+15 -2
View File
@@ -27,14 +27,15 @@ lib64
pip-log.txt pip-log.txt
# Unit test / coverage reports # Unit test / coverage reports
.coverage .coverage*
.tox .tox
nosetests.xml nosetests.xml
coverage.xml coverage.xml
htmlcov htmlcov
junit.xml junit.xml
features/output features/output*
dummy dummy
result.json
# Translations # Translations
*.mo *.mo
@@ -48,12 +49,24 @@ pgpass
scm-source.json scm-source.json
# Sphinx-generated documentation # Sphinx-generated documentation
docs/_build/
docs/build/ docs/build/
docs/source/_static/ docs/source/_static/
docs/source/_templates/ docs/source/_templates/
docs/modules/
docs/pdf/
# Pycharm IDE # Pycharm IDE
.idea/ .idea/
#VSCode IDE #VSCode IDE
.vscode/ .vscode/
# Virtual environment
venv*/
# Default test data directory
data/
# macOS
**/.DS_Store
+26
View File
@@ -0,0 +1,26 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
formats:
- epub
- pdf
- htmlzip
python:
install:
- requirements: requirements.docs.txt
- requirements: requirements.txt
+28 -13
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine ## 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 ## 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 COMPRESS=false
ARG PGHOME=/home/postgres ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data ARG PGDATA=$PGHOME/data
@@ -25,8 +25,7 @@ RUN set -ex \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \ | grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \ | xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \ python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping --fix-missing \ net-tools iputils-ping dumb-init --fix-missing \
&& pip3 install dumb-init \
\ \
# Cleanup all locales but en_US.UTF-8 # Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \ && find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
@@ -53,14 +52,26 @@ RUN set -ex \
&& curl -sL "https://github.com/coreos/etcd/releases/download/v$ETCDVERSION/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \ && curl -sL "https://github.com/coreos/etcd/releases/download/v$ETCDVERSION/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \ | tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
\ \
# Download confd && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \ # Build confd
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \ apt-get install -y git make \
&& curl -sL https://go.dev/dl/go1.20.4.linux-arm64.tar.gz | tar xz -C /usr/local go \
&& export GOROOT=/usr/local/go && export PATH=$PATH:$GOROOT/bin \
&& git clone --recurse-submodules https://github.com/kelseyhightower/confd.git \
&& make -C confd \
&& cp confd/bin/confd /usr/local/bin/confd \
&& rm -rf /confd /usr/local/go; \
else \
# Download confd
curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd; \
fi \
\ \
# Clean up all useless packages and some files # Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \ && apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \ libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \ exim4-config gnupg-agent dirmngr \
git make \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@@ -83,9 +94,9 @@ RUN set -ex \
/usr/share/locale/??_?? \ /usr/share/locale/??_?? \
/usr/share/postgresql/*/man \ /usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \ /usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim80/doc \ /usr/share/vim/vim*/doc \
/usr/share/vim/vim80/lang \ /usr/share/vim/vim*/lang \
/usr/share/vim/vim80/tutor \ /usr/share/vim/vim*/tutor \
# /var/lib/dpkg/info/* \ # /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \ && find /usr/bin -xtype l -delete \
&& find /var/log -type f -exec truncate --size 0 {} \; \ && find /var/log -type f -exec truncate --size 0 {} \; \
@@ -114,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 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 --install -s \
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \ && /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
else \
/bin/busybox --install -s; \
fi fi
FROM scratch FROM scratch
@@ -132,6 +145,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
ENV ETCDCTL_API=3
COPY patroni /patroni/ COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/ COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
@@ -143,14 +157,15 @@ WORKDIR $PGHOME
RUN sed -i 's/env python/&3/' /patroni*.py \ RUN sed -i 's/env python/&3/' /patroni*.py \
# "fix" patroni configs # "fix" patroni configs
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \ && sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \ && sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \ && sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \ && sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
&& sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \ && sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| connect_address\| parameters\):/#&/' postgres?.yml \
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \ && sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
&& sed -i 's/^ parameters:/ pg_hba:\n - local all all trust\n - host replication all all md5\n - host all all all md5\n&\n max_connections: 100/' postgres?.yml \ && sed -i 's/^ parameters:/&\n max_connections: 100/' postgres?.yml \
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' postgres?.yml \
&& sed -i 's/^\(.*\) \(.*\) md5/\1 all md5/' postgres?.yml \
&& if [ "$COMPRESS" = "true" ]; then chmod u+s /usr/bin/sudo; fi \ && if [ "$COMPRESS" = "true" ]; then chmod u+s /usr/bin/sudo; fi \
&& chmod +s /bin/ping \ && chmod +s /bin/ping \
&& chown -R postgres:postgres "$PGHOME" /run /etc/haproxy && chown -R postgres:postgres "$PGHOME" /run /etc/haproxy
+44 -16
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine ## 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 ## 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 COMPRESS=false
ARG PGHOME=/home/postgres ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data ARG PGDATA=$PGHOME/data
@@ -20,15 +20,28 @@ RUN set -ex \
&& export DEBIAN_FRONTEND=noninteractive \ && export DEBIAN_FRONTEND=noninteractive \
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \ && echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
&& apt-get update -y \ && apt-get update -y \
# postgres:10 is based on debian, which has the patroni package. We will install all required dependencies # postgres:PG_MAJOR is based on debian, which has the patroni package. We will install all required dependencies
&& apt-cache depends patroni | sed -n -e 's/.*Depends: \(python3-.\+\)$/\1/p' \ && apt-cache depends patroni | sed -n -e 's/.*Depends: \(python3-.\+\)$/\1/p' \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \ | grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \ | xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \ python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping --fix-missing \ net-tools iputils-ping lsb-release dumb-init --fix-missing \
&& curl https://install.citusdata.com/community/deb.sh | bash \ && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.2 \ apt-get install -y postgresql-server-dev-$PG_MAJOR \
&& pip3 install dumb-init \ git gcc make autoconf \
libc6-dev flex libcurl4-gnutls-dev \
libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev\
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& git clone -b "main" https://github.com/citusdata/citus.git \
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
else \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
fi \
\ \
# Cleanup all locales but en_US.UTF-8 # Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \ && find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
@@ -55,16 +68,29 @@ RUN set -ex \
&& curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-$(dpkg --print-architecture).tar.gz \ && curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-$(dpkg --print-architecture).tar.gz \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \ | tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
\ \
# Download confd && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& curl -sL https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-$(dpkg --print-architecture) \ # Build confd
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \ curl -sL https://go.dev/dl/go1.20.4.linux-arm64.tar.gz | tar xz -C /usr/local go \
&& export GOROOT=/usr/local/go && export PATH=$PATH:$GOROOT/bin \
&& git clone --recurse-submodules https://github.com/kelseyhightower/confd.git \
&& make -C confd \
&& cp confd/bin/confd /usr/local/bin/confd \
&& rm -rf /confd /usr/local/go; \
else \
# Download confd
curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd; \
fi \
# Prepare client cert for HAProxy # Prepare client cert for HAProxy
&& cat /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/certs/ssl-cert-snakeoil.pem > /etc/ssl/private/ssl-cert-snakeoil.crt \ && cat /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/certs/ssl-cert-snakeoil.pem > /etc/ssl/private/ssl-cert-snakeoil.crt \
\ \
# Clean up all useless packages and some files # Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \ && apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \ libmagic1 bsdmainutils login ncurses-bin libmagic-mgc e2fslibs bsdutils \
exim4-config gnupg-agent dirmngr libpython2.7-stdlib libpython2.7-minimal \ exim4-config gnupg-agent dirmngr \
postgresql-server-dev-$PG_MAJOR git gcc make autoconf \
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@@ -87,9 +113,9 @@ RUN set -ex \
/usr/share/locale/??_?? \ /usr/share/locale/??_?? \
/usr/share/postgresql/*/man \ /usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \ /usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim80/doc \ /usr/share/vim/vim*/doc \
/usr/share/vim/vim80/lang \ /usr/share/vim/vim*/lang \
/usr/share/vim/vim80/tutor \ /usr/share/vim/vim*/tutor \
# /var/lib/dpkg/info/* \ # /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \ && find /usr/bin -xtype l -delete \
&& find /var/log -type f -exec truncate --size 0 {} \; \ && find /var/log -type f -exec truncate --size 0 {} \; \
@@ -138,6 +164,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
ENV ETCDCTL_API=3
COPY patroni /patroni/ COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/ COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
@@ -149,16 +176,17 @@ WORKDIR $PGHOME
RUN sed -i 's/env python/&3/' /patroni*.py \ RUN sed -i 's/env python/&3/' /patroni*.py \
# "fix" patroni configs # "fix" patroni configs
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \ && sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \ && sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \ && sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \ && sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
&& sed -i 's/^scope:/log:\n loggers:\n patroni.postgresql.citus: DEBUG\n#&/' postgres?.yml \ && sed -i 's/^scope:/log:\n loggers:\n patroni.postgresql.citus: DEBUG\n#&/' postgres?.yml \
&& sed -i 's/^\(name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \ && sed -i 's/^\(name\|etcd\| host\| authentication\| connect_address\| parameters\):/#&/' postgres?.yml \
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \ && sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
&& sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' postgres?.yml \ && sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' postgres?.yml \
&& sed -i 's|^ parameters:| pg_hba:\n - local all all trust\n - hostssl replication all all md5 clientcert=verify-ca\n - hostssl all all all md5 clientcert=verify-ca\n&\n max_connections: 100\n shared_buffers: 16MB\n ssl: "on"\n ssl_ca_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key\n citus.node_conninfo: "sslrootcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslkey=/etc/ssl/private/ssl-cert-snakeoil.key sslcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslmode=verify-ca"|' postgres?.yml \ && sed -i 's|^ parameters:|&\n max_connections: 100\n shared_buffers: 16MB\n ssl: "on"\n ssl_ca_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key\n citus.node_conninfo: "sslrootcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslkey=/etc/ssl/private/ssl-cert-snakeoil.key sslcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslmode=verify-ca"|' postgres?.yml \
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' postgres?.yml \
&& sed -i 's/^\(.*\) \(.*\) \(.*\) \(.*\) \(.*\) md5.*$/\1 hostssl \3 \4 all md5 clientcert=verify-ca/' postgres?.yml \
&& sed -i 's/^#\(ctl\| certfile\| keyfile\)/\1/' postgres?.yml \ && sed -i 's/^#\(ctl\| certfile\| keyfile\)/\1/' postgres?.yml \
&& sed -i 's|^# cafile: .*$| verify_client: required\n cafile: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \ && sed -i 's|^# cafile: .*$| verify_client: required\n cafile: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \
&& sed -i 's|^# cacert: .*$| cacert: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \ && sed -i 's|^# cacert: .*$| cacert: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \
+18 -26
View File
@@ -8,11 +8,11 @@ You can find a version of this documentation that is searchable and also easier
There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful. Patroni is a template for high availability (HA) PostgreSQL solutions using Python. For maximum accessibility, Patroni supports a variety of distributed configuration stores like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in datacenters - or anywhere else - will hopefully find it useful.
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
Currently supported PostgreSQL versions: 9.3 to 15. Currently supported PostgreSQL versions: 9.3 to 16.
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster. **Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
@@ -49,7 +49,7 @@ We report new releases information `here <https://github.com/zalando/patroni/rel
Community Community
========= =========
There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA>`__. If you're using Patroni, or just interested, please join us. There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__. If you're using Patroni, or just interested, please join us.
=================================== ===================================
Technical Requirements/Installation Technical Requirements/Installation
@@ -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 psycopg2 module on Debian/Ubuntu
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
sudo yum install python-psycopg2 # install python2 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** **General installation for pip**
@@ -119,13 +103,21 @@ kubernetes
raft raft
`pysyncobj` module in order to use python Raft implementation as DCS `pysyncobj` module in order to use python Raft implementation as DCS
aws aws
`boto` in order to use AWS callbacks `boto3` in order to use AWS callbacks
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. 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 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 Environment Configuration
+13 -14
View File
@@ -16,33 +16,33 @@ networks:
services: services:
etcd1: &etcd etcd1: &etcd
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
environment: environment:
ETCDCTL_API: 3
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380 ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379 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 ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: tutorial ETCD_INITIAL_CLUSTER_TOKEN: tutorial
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1 container_name: demo-etcd1
hostname: etcd1 hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380 command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
etcd2: etcd2:
<<: *etcd <<: *etcd
container_name: demo-etcd2 container_name: demo-etcd2
hostname: 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: etcd3:
<<: *etcd <<: *etcd
container_name: demo-etcd3 container_name: demo-etcd3
hostname: 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: haproxy:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: haproxy hostname: haproxy
@@ -52,7 +52,6 @@ services:
- "5001:5001" # Load-balancing across workers primaries - "5001:5001" # Load-balancing across workers primaries
command: haproxy command: haproxy
environment: &haproxy_env environment: &haproxy_env
ETCDCTL_API: 3
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379 ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'" PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
PATRONI_SCOPE: demo PATRONI_SCOPE: demo
@@ -64,7 +63,7 @@ services:
PGSSLROOTCERT: /etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT: /etc/ssl/certs/ssl-cert-snakeoil.pem
coord1: coord1:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: coord1 hostname: coord1
@@ -75,7 +74,7 @@ services:
PATRONI_CITUS_GROUP: 0 PATRONI_CITUS_GROUP: 0
coord2: coord2:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: coord2 hostname: coord2
@@ -85,7 +84,7 @@ services:
PATRONI_NAME: coord2 PATRONI_NAME: coord2
coord3: coord3:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: coord3 hostname: coord3
@@ -96,7 +95,7 @@ services:
work1-1: work1-1:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work1-1 hostname: work1-1
@@ -107,7 +106,7 @@ services:
PATRONI_CITUS_GROUP: 1 PATRONI_CITUS_GROUP: 1
work1-2: work1-2:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work1-2 hostname: work1-2
@@ -118,7 +117,7 @@ services:
work2-1: work2-1:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work2-1 hostname: work2-1
@@ -129,7 +128,7 @@ services:
PATRONI_CITUS_GROUP: 2 PATRONI_CITUS_GROUP: 2
work2-2: work2-2:
image: patroni-citus image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: work2-2 hostname: work2-2
+9 -8
View File
@@ -14,7 +14,7 @@ networks:
services: services:
etcd1: &etcd etcd1: &etcd
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
environment: environment:
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380 ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
@@ -22,24 +22,25 @@ services:
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: tutorial ETCD_INITIAL_CLUSTER_TOKEN: tutorial
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1 container_name: demo-etcd1
hostname: etcd1 hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380 command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
etcd2: etcd2:
<<: *etcd <<: *etcd
container_name: demo-etcd2 container_name: demo-etcd2
hostname: 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: etcd3:
<<: *etcd <<: *etcd
container_name: demo-etcd3 container_name: demo-etcd3
hostname: 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: haproxy:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: haproxy hostname: haproxy
@@ -54,7 +55,7 @@ services:
PATRONI_SCOPE: demo PATRONI_SCOPE: demo
patroni1: patroni1:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: patroni1 hostname: patroni1
@@ -64,7 +65,7 @@ services:
PATRONI_NAME: patroni1 PATRONI_NAME: patroni1
patroni2: patroni2:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: patroni2 hostname: patroni2
@@ -74,7 +75,7 @@ services:
PATRONI_NAME: patroni2 PATRONI_NAME: patroni2
patroni3: patroni3:
image: patroni image: ${PATRONI_TEST_IMAGE:-patroni}
networks: [ demo ] networks: [ demo ]
env_file: docker/patroni.env env_file: docker/patroni.env
hostname: patroni3 hostname: patroni3
+168 -168
View File
@@ -19,102 +19,97 @@ The haproxy listens on ports 5000 (connects to the primary) and 5001 (does load-
Example session: Example session:
$ docker-compose up -d $ docker compose up -d
Creating demo-haproxy ... ✔ Network patroni_demo Created
Creating demo-patroni2 ... ✔ Container demo-etcd1 Started
Creating demo-patroni1 ... ✔ Container demo-haproxy Started
Creating demo-patroni3 ... ✔ Container demo-patroni1 Started
Creating demo-etcd2 ... ✔ Container demo-patroni2 Started
Creating demo-etcd1 ... ✔ Container demo-patroni3 Started
Creating demo-etcd3 ... ✔ Container demo-etcd2 Started
Creating demo-haproxy ✔ Container demo-etcd3 Started
Creating demo-patroni2
Creating demo-patroni1
Creating demo-patroni3
Creating demo-etcd1
Creating demo-etcd2
Creating demo-etcd2 ... done
$ docker ps $ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2 a37bcec56726 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd3
e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1 034ab73868a8 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni2
83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3 03837736f710 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni3
922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3 22815c3d85b3 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd2
14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2 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
110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1 6375b0ba2d0a patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni1
5af5e6e36028 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy aef8bf3ee91f patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd1
$ docker logs demo-patroni1 $ docker logs demo-patroni1
2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul 2023-11-21 09:04:33,547 INFO: Selected new etcd server http://172.29.0.3:2379
2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379 2023-11-21 09:04:33,605 INFO: Lock owner: None; I am patroni1
2019-02-20 08:19:35,140 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:35,174 INFO: trying to bootstrap a new cluster
... ...
2019-02-20 08:19:39,310 INFO: postmaster pid=37 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
2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432 2023-11-21 09:04:34.921 UTC [43] 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" 2023-11-21 09:04:34,922 INFO: postmaster pid=43
2019-02-20 08:19:39.353 UTC [39] LOG: database system was shut down at 2019-02-20 08:19:36 UTC 2023-11-21 09:04:34.922 UTC [43] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up 2023-11-21 09:04:34.925 UTC [47] LOG: database system was shut down at 2023-11-21 09:04:34 UTC
localhost:5432 - rejecting connections 2023-11-21 09:04:34.928 UTC [43] LOG: database system is ready to accept connections
2019-02-20 08:19:39.369 UTC [37] LOG: database system is ready to accept connections
localhost:5432 - accepting connections localhost:5432 - accepting connections
2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster localhost:5432 - accepting connections
2019-02-20 08:19:39,408 INFO: running post_bootstrap 2023-11-21 09:04:34,938 INFO: establishing a new patroni heartbeat connection to postgres
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'" 2023-11-21 09:04:34,992 INFO: running post_bootstrap
2019-02-20 08:19:39,515 INFO: initialized a new cluster 2023-11-21 09:04:35,004 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1 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'
2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1 2023-11-21 09:04:35,189 INFO: initialized a new cluster
2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock 2023-11-21 09:04:35,328 INFO: no action. I am (patroni1), the leader with the lock
2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1 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 $ docker exec -ti demo-patroni1 bash
postgres@patroni1:~$ patronictl list postgres@patroni1:~$ patronictl list
+---------+----------+------------+--------+---------+----+-----------+ + Cluster: demo (7303838734793224214) --------+----+-----------+
| Cluster | Member | Host | Role | State | TL | Lag in MB | | Member | Host | Role | State | TL | Lag in MB |
+---------+----------+------------+--------+---------+----+-----------+ +----------+------------+---------+-----------+----+-----------+
| demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 | | patroni1 | 172.29.0.2 | Leader | running | 1 | |
| demo | patroni2 | 172.22.0.7 | | running | 1 | 0 | | patroni2 | 172.29.0.6 | Replica | streaming | 1 | 0 |
| demo | patroni3 | 172.22.0.4 | | running | 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/config
/service/demo/initialize /service/demo/initialize
/service/demo/leader /service/demo/leader
/service/demo/members/
/service/demo/members/patroni1 /service/demo/members/patroni1
/service/demo/members/patroni2 /service/demo/members/patroni2
/service/demo/members/patroni3 /service/demo/members/patroni3
/service/demo/optime/ /service/demo/status
/service/demo/optime/leader
postgres@patroni1:~$ etcdctl member list postgres@patroni1:~$ etcdctl member list
1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false 2bf3e2ceda5d5960, started, etcd2, http://etcd2:2380, http://172.29.0.3:2379
8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true 55b3264e129c7005, started, etcd3, http://etcd3:2380, http://172.29.0.7:2379
b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false acce7233f8ec127e, started, etcd1, http://etcd1:2380, http://172.29.0.8:2379
postgres@patroni1:~$ exit postgres@patroni1:~$ exit
$ docker exec -ti demo-haproxy bash $ docker exec -ti demo-haproxy bash
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W
Password: postgres 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. Type "help" for help.
localhost/postgres=# select pg_is_in_recovery(); postgres=# SELECT pg_is_in_recovery();
pg_is_in_recovery pg_is_in_recovery
─────────────────── ───────────────────
f f
(1 row) (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 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. Type "help" for help.
localhost/postgres=# select pg_is_in_recovery(); postgres=# SELECT pg_is_in_recovery();
pg_is_in_recovery pg_is_in_recovery
─────────────────── ───────────────────
t t
@@ -127,81 +122,86 @@ The haproxy listens on ports 5000 (connects to the coordinator primary) and 5001
Example session: Example session:
$ docker-compose -f docker-compose-citus.yml up -d $ docker compose -f docker-compose-citus.yml up -d
Creating demo-work2-1 ... done ✔ Network patroni_demo Created
Creating demo-work1-1 ... done ✔ Container demo-coord2 Started
Creating demo-etcd2 ... done ✔ Container demo-work2-2 Started
Creating demo-etcd1 ... done ✔ Container demo-etcd1 Started
Creating demo-coord3 ... done ✔ Container demo-haproxy Started
Creating demo-etcd3 ... done ✔ Container demo-work1-1 Started
Creating demo-coord1 ... done ✔ Container demo-work2-1 Started
Creating demo-haproxy ... done ✔ Container demo-work1-2 Started
Creating demo-work2-2 ... done ✔ Container demo-coord1 Started
Creating demo-coord2 ... done ✔ Container demo-etcd3 Started
Creating demo-work1-2 ... done ✔ Container demo-coord3 Started
✔ Container demo-etcd2 Started
$ docker ps $ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
852d8885a612 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-coord3 79c95492fac9 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd3
cdd692f947ab patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work1-2 77eb82d0f0c1 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-1
9f4e340b36da patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-etcd3 03dacd7267ef patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd1
d69c129a960a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd1 db9206c66f85 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd2
c5849689b8cd patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord1 9a0fef7b7dd4 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-2
c9d72bd6217d patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-1 f06b031d99dc patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-2
24b1b43efa05 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord2 f7c58545f314 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord2
cb0cc2b4ca0a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-2 383f9e7e188a patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-1
9796c6b8aad5 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 5 seconds demo-work1-1 f02e96dcc9d6 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord3
8baccd74dcae patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd2 6945834b7056 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord1
353ec62a0187 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy 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 $ docker logs demo-coord1
2023-01-05 15:09:31,295 INFO: Selected new etcd server http://172.27.0.4:2379 2023-11-21 09:36:14,293 INFO: Selected new etcd server http://172.30.0.4:2379
2023-01-05 15:09:31,388 INFO: Lock owner: None; I am coord1 2023-11-21 09:36:14,390 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,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 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-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-01-05 15:09:45.137 UTC [39] LOG: listening on IPv4 address "0.0.0.0", port 5432 2023-11-21 09:36:16.495 UTC [52] 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-11-21 09:36:16.496 UTC [52] 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-11-21 09:36:16.498 UTC [56] LOG: database system was shut down at 2023-11-21 09:36:15 UTC
2023-01-05 15:09:45.193 UTC [39] LOG: database system is ready to accept connections 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
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-11-21 09:36:17,509 INFO: establishing a new patroni heartbeat connection to postgres
2023-01-05 15:09:46,208 INFO: running post_bootstrap 2023-11-21 09:36:17,569 INFO: running post_bootstrap
2023-01-05 15:09:47.209 UTC [55] LOG: starting maintenance daemon on database 16386 user 10 2023-11-21 09:36:17,593 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
2023-01-05 15:09:47.209 UTC [55] CONTEXT: Citus maintenance daemon for database 16386 user 10 2023-11-21 09:36:17,783 INFO: establishing a new patroni restapi connection to postgres
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-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-01-05 15:09:47.446 UTC [41] LOG: checkpoint starting: immediate force wait 2023-11-21 09:36:17.969 UTC [70] LOG: starting maintenance daemon on database 16386 user 10
2023-01-05 15:09:47,466 INFO: initialized a new cluster 2023-11-21 09:36:17.969 UTC [70] CONTEXT: Citus maintenance daemon for database 16386 user 10
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-11-21 09:36:18.159 UTC [54] LOG: checkpoint starting: immediate force wait
2023-01-05 15:09:47,594 INFO: establishing a new patroni connection to the postgres cluster 2023-11-21 09:36:18,162 INFO: initialized a new cluster
2023-01-05 15:09:47,467 INFO: Lock owner: coord1; I am coord1 2023-11-21 09:36:18,164 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-11-21 09:36:18,297 INFO: Enabled synchronous replication
2023-01-05 15:09:47,924 INFO: no action. I am (coord1), the leader with the lock 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-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-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-01-05 15:09:51.283 UTC [41] LOG: checkpoint starting: immediate force wait 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-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-11-21 09:36:18,299 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
2023-01-05 15:09:57,467 INFO: Lock owner: coord1; I am coord1 2023-11-21 09:36:18,299 INFO: establishing a new patroni citus connection to postgres
2023-01-05 15:09:57,569 INFO: Assigning synchronous standby status to ['coord3'] 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 server signaled
2023-01-05 15:09:57.574 UTC [39] LOG: received SIGHUP, reloading configuration files 2023-11-21 09:36:28.435 UTC [52] LOG: received SIGHUP, reloading configuration files
2023-01-05 15:09:57.580 UTC [39] LOG: parameter "synchronous_standby_names" changed to "coord3" 2023-11-21 09:36:28.436 UTC [52] LOG: parameter "synchronous_standby_names" changed to "coord3"
2023-01-05 15:09:59,637 INFO: Synchronous standby status assigned to ['coord3'] 2023-11-21 09:36:28.641 UTC [83] LOG: standby "coord3" is now a synchronous standby with priority 1
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-11-21 09:36:28.641 UTC [83] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
2023-01-05 15:09:59.690 UTC [67] LOG: standby "coord3" is now a synchronous standby with priority 1 2023-11-21 09:36:30,582 INFO: Synchronous standby status assigned to ['coord3']
2023-01-05 15:09:59.690 UTC [67] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1 2023-11-21 09:36:30,626 INFO: no action. I am (coord1), the leader with the lock
2023-01-05 15:09:59,694 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
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
$ docker exec -ti demo-haproxy bash $ docker exec -ti demo-haproxy bash
postgres@haproxy:~$ etcdctl member list postgres@haproxy:~$ etcdctl member list
1bab629f01fa9065, started, etcd3, http://etcd3:2380, http://172.27.0.10:2379 2b28411e74c0c281, started, etcd3, http://etcd3:2380, http://172.30.0.4:2379
8ecb6af518d241cc, started, etcd2, http://etcd2:2380, http://172.27.0.4:2379 6c70137d27cfa6c1, started, etcd2, http://etcd2:2380, http://172.30.0.5:2379
b2e169fcb8a34028, started, etcd1, http://etcd1:2380, http://172.27.0.7:2379 a28f9a70ebf21304, started, etcd1, http://etcd1:2380, http://172.30.0.6:2379
postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo
/service/demo/0/config /service/demo/0/config
@@ -229,7 +229,7 @@ Example session:
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
Password for user postgres: postgres 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) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help. Type "help" for help.
@@ -240,67 +240,67 @@ Example session:
(1 row) (1 row)
citus=# table pg_dist_node; 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 1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t 2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
3 | 2 | 172.27.0.8 | 5432 | default | t | t | primary | default | t | t 3 | 2 | 172.30.0.8 | 5432 | default | t | t | primary | default | t | t
(3 rows) (3 rows)
citus=# \q citus=# \q
postgres@haproxy:~$ patronictl list postgres@haproxy:~$ patronictl list
+ Citus cluster: demo ----------+--------------+---------+----+-----------+ + Citus cluster: demo ----------+--------------+-----------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB | | Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+---------+----+-----------+ +-------+---------+-------------+--------------+-----------+----+-----------+
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | | | 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 | | 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 | | 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | | | 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 | | 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
| 2 | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 | | 2 | work2-1 | 172.30.0.8 | Leader | running | 1 | |
| 2 | work2-2 | 172.27.0.8 | Leader | running | 1 | | | 2 | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
+-------+---------+-------------+--------------+---------+----+-----------+ +-------+---------+-------------+--------------+-----------+----+-----------+
postgres@haproxy:~$ patronictl switchover --group 2 --force postgres@haproxy:~$ patronictl switchover --group 2 --force
Current cluster topology Current cluster topology
+ Citus cluster: demo (group: 2, 7185185529556963355) +-----------+ + Citus cluster: demo (group: 2, 7303846899271086103) --+-----------+
| Member | Host | Role | State | TL | Lag in MB | | Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+--------------+---------+----+-----------+ +---------+-------------+--------------+-----------+----+-----------+
| work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 | | work2-1 | 172.30.0.8 | Leader | running | 1 | |
| work2-2 | 172.27.0.8 | Leader | running | 1 | | | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
+---------+-------------+--------------+---------+----+-----------+ +---------+-------------+--------------+-----------+----+-----------+
2023-01-05 15:29:29.54204 Successfully switched over to "work2-1" 2023-11-21 09:44:15.83849 Successfully switched over to "work2-2"
+ Citus cluster: demo (group: 2, 7185185529556963355) -------+ + Citus cluster: demo (group: 2, 7303846899271086103) -------+
| Member | Host | Role | State | TL | Lag in MB | | Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+---------+---------+----+-----------+ +---------+-------------+---------+---------+----+-----------+
| work2-1 | 172.27.0.11 | Leader | running | 1 | | | work2-1 | 172.30.0.8 | Replica | stopped | | unknown |
| work2-2 | 172.27.0.8 | Replica | stopped | | unknown | | work2-2 | 172.30.0.11 | Leader | running | 1 | |
+---------+-------------+---------+---------+----+-----------+ +---------+-------------+---------+---------+----+-----------+
postgres@haproxy:~$ patronictl list postgres@haproxy:~$ patronictl list
+ Citus cluster: demo ----------+--------------+---------+----+-----------+ + Citus cluster: demo ----------+--------------+-----------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB | | Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+---------+----+-----------+ +-------+---------+-------------+--------------+-----------+----+-----------+
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | | | 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 | | 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 | | 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | | | 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 | | 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
| 2 | work2-1 | 172.27.0.11 | Leader | running | 2 | | | 2 | work2-1 | 172.30.0.8 | Sync Standby | streaming | 2 | 0 |
| 2 | work2-2 | 172.27.0.8 | Sync Standby | running | 2 | 0 | | 2 | work2-2 | 172.30.0.11 | Leader | running | 2 | |
+-------+---------+-------------+--------------+---------+----+-----------+ +-------+---------+-------------+--------------+-----------+----+-----------+
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
Password for user postgres: postgres psql (15.5 (Debian 15.5-1.pgdg120+1))
psql (15.1 (Debian 15.1-1.pgdg110+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help. Type "help" for help.
citus=# table pg_dist_node; 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 1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f
3 | 2 | 172.27.0.11 | 5432 | default | t | t | primary | default | t | t 3 | 2 | 172.30.0.11 | 5432 | default | t | t | primary | default | t | t
2 | 1 | 172.27.0.2 | 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 rows)
+3 -1
View File
@@ -13,6 +13,8 @@ readonly PATRONI_NAMESPACE="${PATRONI_NAMESPACE%/}"
DOCKER_IP=$(hostname --ip-address) DOCKER_IP=$(hostname --ip-address)
readonly DOCKER_IP readonly DOCKER_IP
export DUMB_INIT_SETSID=0
case "$1" in case "$1" in
haproxy) haproxy)
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D 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_SSLCERT="${PATRONI_SUPERUSER_SSLCERT:-$PGSSLCERT}"
export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}" export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}"
exec python3 /patroni.py postgres0.yml exec dumb-init python3 /patroni.py postgres0.yml
+7 -57
View File
@@ -1,62 +1,12 @@
.. _contributing: .. _contributing:
Contributing guidelines Contributing
======================= ============
Wanna contribute to Patroni? Yay - here is how! Resources and information for developers can be found in the pages below.
Chatting .. toctree::
-------- :maxdepth: 2
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://join.slack.com/t/postgresteam/shared_invite/zt-1qj14i9sj-E9WqIFlvcOiHsEk2yFEMjA>`__. contributing_guidelines
Patroni API docs<modules/modules>
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`.
Install dependencies:
.. code-block:: bash
# You may want to use Virtualenv or specify pip3.
pip install -r requirements.txt
pip install -r requirements.dev.txt
After you have all dependencies installed, you can run the various test suites:
.. code-block:: bash
# You may want to use Virtualenv or specify python3.
# Run flake8 to check syntax and formatting:
python setup.py flake8
# Run the pytest suite in tests/:
python setup.py test
# 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
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.
You'll get feedback about your pull request as soon as possible.
Happy Patroni hacking ;-)
+40 -19
View File
@@ -14,25 +14,24 @@ Global/Universal
Log 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\_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\_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\_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\_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\_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\_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\_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}"`` - **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 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>`. 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\_KEY\_PASSWORD**: (optional) The client key password.
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``. - **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``. - **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:: .. note::
It is required to install ``kazoo>=2.6.0`` to support SSL. It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -112,7 +112,11 @@ Kubernetes
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`. - **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`.
- **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates. - **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`. - **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`. - **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **PATRONI\_KUBERNETES\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `master`. Default value is `master`.
- **PATRONI\_KUBERNETES\_FOLLOWER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `replica`. Default value is `replica`.
- **PATRONI\_KUBERNETES\_STANDBY\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``master``.
- **PATRONI\_KUBERNETES\_TMP\_ROLE\_LABEL**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state. - **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted. - **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set. - **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
@@ -135,7 +139,14 @@ PostgreSQL
- **PATRONI\_POSTGRESQL\_PROXY\_ADDRESS**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery. - **PATRONI\_POSTGRESQL\_PROXY\_ADDRESS**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
- **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni. - **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
- **PATRONI\_POSTGRESQL\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni. - **PATRONI\_POSTGRESQL\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
- **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables. - **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, initdb, pg_controldata, pg_basebackup, postgres, pg_isready, pg_rewind) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_CTL**: (optional) Custom name for ``pg_ctl`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_INITDB**: (optional) Custom name for ``initdb`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_CONTROLDATA**: (optional) Custom name for ``pg_controldata`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_BASEBACKUP**: (optional) Custom name for ``pg_basebackup`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_POSTGRES**: (optional) Custom name for ``postgres`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_IS\_READY**: (optional) Custom name for ``pg_isready`` binary.
- **PATRONI\_POSTGRESQL\_BIN\_PG\_REWIND**: (optional) Custom name for ``pg_rewind`` binary.
- **PATRONI\_POSTGRESQL\_PGPASS**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni. - **PATRONI\_POSTGRESQL\_PGPASS**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni.
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication - **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization. - **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
@@ -159,8 +170,8 @@ PostgreSQL
- **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted. - **PATRONI\_REWIND\_USERNAME**: (optional) name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **PATRONI\_REWIND\_PASSWORD**: password for the user for ``pg_rewind``; the user will be created during initialization. - **PATRONI\_REWIND\_PASSWORD**: (optional) password for the user for ``pg_rewind``; the user will be created during initialization.
- **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``. - **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate. - **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``. - **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``.
@@ -187,11 +198,21 @@ REST API
- **PATRONI\_RESTAPI\_ALLOWLIST\_INCLUDE\_MEMBERS**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections. - **PATRONI\_RESTAPI\_ALLOWLIST\_INCLUDE\_MEMBERS**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response. - **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``. - **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
- **PATRONI\_RESTAPI\_REQUEST\_QUEUE\_SIZE**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
.. warning::
- The ``PATRONI_RESTAPI_CONNECT_ADDRESS`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
- If you enabled client certificates validation (``PATRONI_RESTAPI_VERIFY_CLIENT`` is set to ``required``), you also **must** provide **valid client certificates** in the ``PATRONI_CTL_CERTFILE``, ``PATRONI_CTL_KEYFILE``, ``PATRONI_CTL_KEYFILE_PASSWORD``. If not provided, Patroni will not work correctly.
CTL CTL
--- ---
- **PATRONICTL\_CONFIG\_FILE**: location of the configuration file. - **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
- **PATRONI\_CTL\_INSECURE**: Allow connections to REST API without verifying SSL certs. - **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_CACERT**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter. - **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_CERTFILE**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter. - **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_KEYFILE**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter. - **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :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.
+4 -84
View File
@@ -4,7 +4,7 @@
Introduction Introduction
============ ============
Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features. Patroni is a template for high availability (HA) PostgreSQL solutions using Python. Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando. For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
@@ -25,83 +25,7 @@ We report new releases information :ref:`here <releases>`.
Technical Requirements/Installation Technical Requirements/Installation
----------------------------------- -----------------------------------
**Pre-requirements for Mac OS** Go :ref:`here <installation>` for guidance on installing and upgrading Patroni on various platforms.
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
`boto` 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.
.. _running_configuring: .. _running_configuring:
@@ -145,7 +69,7 @@ run:
YAML Configuration YAML Configuration
------------------ ------------------
Go :ref:`here <settings>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__. Go :ref:`here <yaml_configuration>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
Environment Configuration Environment Configuration
@@ -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. 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 Testing Your HA Solution
-------------------------------------- --------------------------------------
@@ -179,7 +99,7 @@ That said, here are some pieces of your infrastructure you should be sure to tes
* Network (the network in front of your system as well as the NICs [physical or virtual] themselves) * Network (the network in front of your system as well as the NICs [physical or virtual] themselves)
* Disk IO * Disk IO
* file limits (nofile in Linux) * file limits (nofile in Linux)
* RAM. Even if you have oomkiller turned off as suggested, the unavailability of RAM could cause issues. * RAM. Even if you have oomkiller turned off, the unavailability of RAM could cause issues.
* CPU * CPU
* Virtualization Contention (overcommitting the hypervisor) * Virtualization Contention (overcommitting the hypervisor)
* Any cgroup limitation (likely to be related to the above) * Any cgroup limitation (likely to be related to the above)
-410
View File
@@ -1,410 +0,0 @@
.. _settings:
===========================
YAML Configuration Settings
===========================
.. _dynamic_configuration_settings:
Dynamic configuration settings
------------------------------
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes. Some parameters, like **loop_wait**, **ttl**, **postgresql.parameters.max_connections**, **postgresql.parameters.max_worker_processes** and so on could be set only in the dynamic configuration. Some other parameters like **postgresql.listen**, **postgresql.data_dir** could be set only locally, i.e. in the Patroni config file or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration. In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
- **postgresql**:
- **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
- **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
- **parameters**: list of configuration settings for Postgres.
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
- **host**: an address of remote node
- **port**: a port of remote node
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **my_slot_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
- **database**: the database name where logical slots should be created.
- **plugin**: the plugin name for the logical slot.
- **ignore_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
- **name**: the name of the replication slot.
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
- **database**: the database name (when matching a ``logical`` slot).
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
.. code:: YAML
slots:
permanent_logical_slot_name:
type: logical
database: my_db
plugin: test_decoding
permanent_physical_slot_name:
type: physical
...
ignore_slots:
- name: ignored_logical_slot_name
type: logical
database: my_db
plugin: test_decoding
- name: ignored_physical_slot_name
type: physical
...
Global/Universal
----------------
- **name**: the name of the host. Must be unique for the cluster.
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
Log
---
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **loggers**: This section allows redefining logging level per python module
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
.. _bootstrap_settings:
Bootstrap configuration
-----------------------
- **bootstrap**:
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing of new cluster. The global dynamic configuration for the cluster. Under the ``bootstrap.dcs`` you can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration_settings>` and after Patroni initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store. All later changes of ``bootstrap.dcs`` will not take any effect! If you want to change them please use either ``patronictl edit-config`` or Patroni :ref:`REST API <rest_api>`.
- **method**: custom script to use for bootstrapping this cluster.
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
parameter is present in the configuration file.
- **initdb**: List options to be passed on to initdb.
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
- **- host all all 0.0.0.0/0 md5**.
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **users**: Some additional users which need to be created after initializing new cluster
- **admin**: the name of user
- **password: zalando**:
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _citus_settings:
Citus
-----
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
- **group**: the Citus group id, integer. Use ``0`` for coordinator and ``1``, ``2``, etc... for workers
- **database**: the database where ``citus`` extension should be created. Must be the same on the coordinator and all workers. Currently only one database is supported.
.. _consul_settings:
Consul
------
Most of the parameters are optional, but you have to specify one of the **host** or **url**
- **host**: the host:port for the Consul local agent.
- **url**: url for the Consul local agent, in format: http(s)://host:port.
- **port**: (optional) Consul port.
- **scheme**: (optional) **http** or **https**, defaults to **http**.
- **token**: (optional) ACL token.
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**.
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``primary``/``replica``/``standby-leader``). By default an empty list is used.
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
The ``token`` needs to have the following ACL permissions:
::
service_prefix "${scope}" {
policy = "write"
}
key_prefix "${namespace}/${scope}" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
Etcd
----
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
- **host**: the host:port for the etcd endpoint.
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
- **url**: url for the etcd.
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **username**: (optional) username for etcd authentication.
- **password**: (optional) password for etcd authentication.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
Etcdv3
------
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
.. warning::
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
ZooKeeper
----------
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
- **cacert**: (optional) The CA certificate. If present it will enable validation.
- **cert**: (optional) File with the client certificate.
- **key**: (optional) File with the client key.
- **key_password**: (optional) The client key password.
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
Exhibitor
---------
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor.
- **port**: Exhibitor port.
.. _kubernetes_settings:
Kubernetes
----------
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
- **retriable\_http\_codes**: (optional) list of HTTP status codes from K8s API to retry on. By default Patroni is retrying on ``500``, ``503``, and ``504``, or if K8s API response has ``retry-after`` HTTP header.
.. _raft_settings:
Raft (deprecated)
-----------------
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
Short FAQ about Raft implementation
- Q: How to list all the nodes providing consensus?
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
- Q: Where to get the ``syncobj_admin`` utility?
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
- Q: it is possible to run Patroni node without adding in to the consensus?
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
.. _postgresql_settings:
PostgreSQL
----------
- **postgresql**:
- **authentication**:
- **superuser**:
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
- **password**: password for the superuser, set during initialization (initdb).
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **replication**:
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
- **password**: replication password; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **rewind**:
- **username**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **password**: password for the user for ``pg_rewind``; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
- **on\_reload**: run this script when configuration reload is triggered.
- **on\_restart**: run this script when the postgres restarts (without changing role).
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
- **on\_start**: run this script when the postgres starts.
- **on\_stop**: run this script when the postgres stops.
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
- **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables.
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. This parameter has higher priority than ``bootstrap.pg_hba``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_hba.conf``.
- **- host all all 0.0.0.0/0 md5**.
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_ident.conf``.
- **- mapname1 systemname1 pguser1**.
- **- mapname1 systemname2 pguser2**.
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
.. _restapi_settings:
REST API
--------
- **restapi**:
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
- **authentication**: (optional)
- **username**: Basic-auth username to protect unsafe REST API endpoints.
- **password**: Basic-auth password to protect unsafe REST API endpoints.
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
Here is an example of both **http_extra_headers** and **https_extra_headers**:
.. code:: YAML
restapi:
listen: <listen>
connect_address: <connect_address>
authentication:
username: <username>
password: <password>
http_extra_headers:
'X-Frame-Options': 'SAMEORIGIN'
'X-XSS-Protection': '1; mode=block'
'X-Content-Type-Options': 'nosniff'
cafile: <ca file>
certfile: <cert>
keyfile: <key>
https_extra_headers:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
.. _patronictl_settings:
CTL
---
- **ctl**: (optional)
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
- **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
- **keyfile\_password**: Specifies a password for decrypting the keyfile. If not provided patronictl will use the value provided for REST API "keyfile\_password" parameter.
Watchdog
--------
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
.. _tags_settings:
Tags
----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
In addition to these predefined tags, you can also add your own ones:
- **key1**: ``true``
- **key2**: ``false``
- **key3**: ``1.4``
- **key4**: ``"RandomString"``
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
+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 2. If ``max_prepared_transactions`` isn't explicitly set in the global
:ref:`dynamic configuration <dynamic_configuration>` Patroni will :ref:`dynamic configuration <dynamic_configuration>` Patroni will
automatically set it to ``2*max_connections``. automatically set it to ``2*max_connections``.
3. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``. 3. The ``citus.local_hostname`` GUC value will be adjusted from ``localhost`` to the
4. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo`` 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 table to allow cross-node communication. Don't forget to update them if
later you decide to change superuser username/password/sslcert/sslkey! 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 nodes and add them to the ``pg_dist_node`` table using the
``citus_add_node()`` function. ``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. on the coordinator or worker clusters occurs.
patronictl 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 PostgreSQL. Therefore in most cases it is not possible to manage them as a
single entity. 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: ``patroni.yaml`` has the ``citus`` section comparing with the usual:
1. The ``list`` and the ``topology`` by default output all members of the Citus 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. which Citus group they belong to.
2. For all ``patronictl`` commands the new option is introduced, named 2. For all ``patronictl`` commands the new option is introduced, named
``--group``. For some commands the default value for the group might be ``--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 enable the maintenance mode by default for the ``group`` that is set in the
``citus`` section, but for example for ``patronictl switchover`` or ``citus`` section, but for example for :ref:`patronictl_switchover` or
``patronictl remove`` the group must be explicitly specified. :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 postgres@coord1:~$ patronictl list demo
+ Citus cluster: 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 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. 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 postgres@coord1:~$ patronictl switchover demo
+ Citus cluster: demo ----------+--------------+---------+----+-----------+ + Citus cluster: demo ----------+--------------+---------+----+-----------+
@@ -343,7 +347,7 @@ Citus upgrades and PostgreSQL major upgrades
First, please read about upgrading Citus version in the `documentation`__. First, please read about upgrading Citus version in the `documentation`__.
There is one minor change in the process. When executing upgrade, you have to 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. PostgreSQL.
__ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html __ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html
+117 -7
View File
@@ -20,10 +20,15 @@
import os import os
import sys import sys
sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('..'))
from patroni.version import __version__ from patroni.version import __version__
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
module_dir = os.path.abspath(os.path.join(project_root, 'patroni'))
excludes = ['tests', 'setup.py', 'conf']
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # If your documentation needs a minimal Sphinx version, state it here.
@@ -33,11 +38,28 @@ from patroni.version import __version__
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = ['sphinx.ext.intersphinx', extensions = [
'sphinx.ext.intersphinx',
'sphinx.ext.todo', 'sphinx.ext.todo',
'sphinx.ext.mathjax', 'sphinx.ext.mathjax',
'sphinx.ext.ifconfig', 'sphinx.ext.ifconfig',
'sphinx.ext.viewcode'] # 'sphinx.ext.viewcode',
'sphinx_github_style', # Generate "View on GitHub" for source code
'sphinxcontrib.apidoc', # For generating module docs from code
'sphinx.ext.autodoc', # For generating module docs from docstrings
'sphinx.ext.napoleon', # For Google and Numpy formatted docstrings
]
apidoc_module_dir = module_dir
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. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ['_templates']
@@ -70,7 +92,7 @@ release = __version__
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # Usually you set "language" from the command line for these cases.
language = None language = 'en'
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
@@ -90,10 +112,10 @@ todo_include_todos = True
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = 'sphinx_rtd_theme'
on_rtd = os.environ.get('READTHEDOCS', None) == 'True' on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
if not on_rtd: # only import and set the theme if we're building docs locally if not on_rtd: # only import and set the theme if we're building docs locally
import sphinx_rtd_theme import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Theme options are theme-specific and customize the look and feel of a theme # Theme options are theme-specific and customize the look and feel of a theme
@@ -107,6 +129,34 @@ if not on_rtd: # only import and set the theme if we're building docs locally
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ['_static']
# Replace "source" links with "edit on GitHub" when using rtd theme
html_context = {
'display_github': True,
'github_user': 'zalando',
'github_repo': 'patroni',
'github_version': 'master',
'conf_py_path': '/docs/',
}
# sphinx-github-style options, https://sphinx-github-style.readthedocs.io/en/latest/index.html
# The name of the top-level package.
top_level = "patroni"
# The blob to link to on GitHub - any of "head", "last_tag", or "{blob}"
# linkcode_blob = 'head'
# The link to your GitHub repository formatted as https://github.com/user/repo
# If not provided, will attempt to create the link from the html_context dict
# linkcode_url = f"https://github.com/{html_context['github_user']}/" \
# f"{html_context['github_repo']}/{html_context['github_version']}"
# The text to use for the linkcode link
# linkcode_link_text: str = "View on GitHub"
# A linkcode_resolve() function to use for resolving the link target
# linkcode_resolve: types.FunctionType
# -- Options for HTMLHelp output ------------------------------------------ # -- Options for HTMLHelp output ------------------------------------------
@@ -165,7 +215,6 @@ texinfo_documents = [
] ]
# -- Options for Epub output ---------------------------------------------- # -- Options for Epub output ----------------------------------------------
# Bibliographic Dublin Core info. # Bibliographic Dublin Core info.
@@ -187,9 +236,64 @@ epub_copyright = copyright
epub_exclude_files = ['search.html'] epub_exclude_files = ['search.html']
# Example configuration for intersphinx: refer to the Python standard library. # Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/': None} intersphinx_mapping = {'python': ('https://docs.python.org/', None)}
# Remove these pages from index, references, toc trees, etc.
# If the builder is not 'html' then add the API docs modules index to pages to be removed.
exclude_from_builder = {
'latex': ['modules/modules'],
'epub': ['modules/modules'],
}
# Internal holding list, anything added here will always be excluded
_docs_to_remove = []
def builder_inited(app):
"""Run during Sphinx `builder-inited` phase.
Set a config value to builder name and add module docs to `docs_to_remove`.
"""
print(f'The builder is: {app.builder.name}')
app.add_config_value('builder', app.builder.name, 'env')
# Remove pages when builder matches any referenced in exclude_from_builder
if exclude_from_builder.get(app.builder.name):
_docs_to_remove.extend(exclude_from_builder[app.builder.name])
def env_get_outdated(app, env, added, changed, removed):
"""Run during Sphinx `env-get-outdated` phase.
Remove the items listed in `docs_to_remove` from known pages.
"""
added.difference_update(_docs_to_remove)
changed.difference_update(_docs_to_remove)
removed.update(_docs_to_remove)
return []
def doctree_read(app, doctree):
"""Run during Sphinx `doctree-read` phase.
Remove the items listed in `docs_to_remove` from the table of contents.
"""
from sphinx import addnodes
for toc_tree_node in doctree.traverse(addnodes.toctree):
for e in toc_tree_node['entries']:
ref = str(e[1])
if ref in _docs_to_remove:
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 # A possibility to have an own stylesheet, to add new rules or override existing ones
# For the latter case, the CSS specificity of the rules should be higher than the default ones # For the latter case, the CSS specificity of the rules should be higher than the default ones
@@ -198,3 +302,9 @@ def setup(app):
app.add_css_file('custom.css') app.add_css_file('custom.css')
else: else:
app.add_stylesheet('custom.css') app.add_stylesheet('custom.css')
# Run extra steps to remove module docs when running with a non-html builder
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)
+190
View File
@@ -0,0 +1,190 @@
.. _contributing_guidelines:
Contributing guidelines
=======================
.. _chatting:
Chatting
--------
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:
#. 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:
.. code-block:: bash
# You may want to use Virtualenv or specify pip3.
pip install -r requirements.txt
pip install -r requirements.dev.txt
After you have all dependencies installed, you can run the various test suites:
.. code-block:: bash
# You may want to use Virtualenv or specify python3.
# Run flake8 to check syntax and formatting:
python setup.py flake8
# 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
Testing with tox
----------------
To run tox tests you only need to install one dependency (other than Python)
.. code-block:: bash
pip install tox>=4
If you wish to run `behave` tests then you also need docker installed.
Tox configuration in `tox.ini` has "environments" to run the following tasks:
* lint: Python code lint with `flake8`
* test: unit tests for all available python interpreters with `pytest`,
generates XML reports or HTML reports if a TTY is detected
* dep: detect package dependency conflicts using `pipdeptree`
* type: static type checking with `pyright`
* black: code formatting with `black`
* docker-build: build docker image used for the `behave` env
* docker-cmd: run arbitrary command with the above image
* docker-behave-etcd: run tox for behave tests with above image
* py*behave: run behave with available python interpreters (without docker, although
this is what is called inside docker containers)
* docs: build docs with `sphinx`
Running tox
^^^^^^^^^^^
To run the default env list; dep, lint, test, and docs, just run:
.. code-block:: bash
tox
The `test` envs can be run with the label `test`:
.. code-block:: bash
tox -m test
The `behave` docker tests can be run with the label `behave`:
.. code-block:: bash
tox -m behave
Similarly, docs has the label `docs`.
All other envs can be run with their respective env names:
.. code-block:: bash
tox -e lint
tox -e py39-test-lin
It is also possible to select partial env lists using `factors`. For example, if you want to run
all envs for python 3.10:
.. code-block:: bash
tox -f py310
This is equivalent to running all the envs listed below:
.. code-block:: bash
$ tox -l -f py310
py310-test-lin
py310-test-mac
py310-test-win
py310-type-lin
py310-type-mac
py310-type-win
py310-behave-etcd-lin
py310-behave-etcd-win
py310-behave-etcd-mac
You can list all configured combinations of environments with tox (>=v4) like so
.. code-block:: bash
tox l
The envs `test` and `docs` will attempt to open the HTML output files
when the job completes, if tox is run with an active terminal. This
is intended to be for benefit of the developer running this env locally.
It will attempt to run `open` on a mac and `xdg-open` on Linux.
To use a different command set the env var `OPEN_CMD` to the name or path of
the command. If this step fails it will not fail the run overall.
If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op command.
.. code-block:: bash
OPEN_CMD=: tox -m docs
Behave tests
^^^^^^^^^^^^
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 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 14 use:
.. code-block:: bash
tox -e pg14-docker-build,pg14-docker-behave-etcd-lin
If on the other hand you want to test a specific feature you can pass positional arguments to behave. This will run
the watchdog behave feature test scenario with all versions of Postgres.
.. code-block:: bash
tox -m behave -- features/watchdog.feature
Of course you can combine the two.
Contributing 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.
Happy Patroni hacking ;-)
+3 -3
View File
@@ -23,7 +23,7 @@ In general, it is impossible to distinguish between these two from a single node
DCS Failsafe Mode DCS Failsafe Mode
----------------- -----------------
We introduce a new special option, the ``failsafe_mode``. It could be enabled only via global configuration stored in the DCS ``/config`` key. If the failsafe mode is enabled and the leader lock update in DCS failed due to reasons different from the version/value/index mismatch, Postgres may continue to run as a primary if it can access all known members of the cluster via Patroni REST API. We introduce a new special option, the ``failsafe_mode``. It could be enabled only via global :ref:`dynamic configuration <dynamic_configuration>` stored in the DCS ``/config`` key. If the failsafe mode is enabled and the leader lock update in DCS failed due to reasons different from the version/value/index mismatch, Postgres may continue to run as a primary if it can access all known members of the cluster via Patroni REST API.
Low-level implementation details Low-level implementation details
@@ -53,11 +53,11 @@ F.A.Q.
- What if all members of the Patroni cluster are lost while DCS is down? - What if all members of the Patroni cluster are lost while DCS is down?
Patroni could be configured to create the new replica from the backup even when the cluster doesn't have a leader. But, if the new member isn't present in the ``/failsafe`` key, it will not be able to grab the leader lock and promote. Patroni could be configured to create the new replica from the backup even when the cluster doesn't have a leader. But, if the new member isn't present in the ``/failsafe`` key, it will not be able to grab the leader lock and promote.
- What will happen if the primary lost access to DCS while replicas didn't? - What will happen if the primary lost access to DCS while replicas didn't?
The primary will execute the failsafe code and contact all known replicas. These replicas will use this information as an indicator that the primary is alive and will not start the leader race even if the leader lock in DCS has expired. The primary will execute the failsafe code and contact all known replicas. These replicas will use this information as an indicator that the primary is alive and will not start the leader race even if the leader lock in DCS has expired.
- How to enable the Failsafe Mode? - 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>`
+96 -71
View File
@@ -1,89 +1,114 @@
.. _dynamic_configuration: .. _dynamic_configuration:
Patroni configuration ==============================
===================== Dynamic Configuration Settings
==============================
Patroni configuration is stored in the DCS (Distributed Configuration Store). There are 3 types of configuration: Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
- Dynamic configuration. In order to change the dynamic configuration you can use either :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
These options can be set in DCS at any time. If the options changed are not part of the startup configuration,
they are applied asynchronously (upon the next wake up cycle) to every node, which gets subsequently reloaded.
If the node requires a restart to apply the configuration (for options with context postmaster, if their values
have changed), a special flag, ``pending_restart`` indicating this, is set in the members.data JSON.
Additionally, the node status also indicates this, by showing ``"restart_pending": true``.
- Local :ref:`configuration <settings>` (patroni.yml). - **loop\_wait**: the number of seconds the loop will sleep. Default value: 10, minimum possible value: 1
These options are defined in the configuration file and take precedence over dynamic configuration. - **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30, minimum possible value: 20
patroni.yml could be changed and reloaded in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. - **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10, minimum possible value: 3
- Environment :ref:`configuration <environment>`. .. warning::
It is possible to set/override some of the "Local" configuration parameters with environment variables. when changing values of **loop_wait**, **retry_timeout**, or **ttl** you have to follow the rule:
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``).
The local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence. .. code-block:: python
Some of the PostgreSQL parameters must hold the same values on the primary and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values: loop_wait + 2 * retry_timeout <= ttl
- max_connections: 100
- max_locks_per_transaction: 64
- max_worker_processes: 8
- max_prepared_transactions: 0
- wal_level: hot_standby
- wal_log_hints: on
- track_commit_timestamp: off
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
- max_wal_senders: 5
- max_replication_slots: 5
- wal_keep_segments: 8
- wal_keep_size: 128MB
These parameters are validated to ensure they are sane, or meet a minimum value.
There are some other Postgres parameters controlled by Patroni:
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- cluster_name - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
- hot_standby: on
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
When applying the local or dynamic configuration options, the following actions are taken: - **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
- **postgresql**:
- The node first checks if there is a postgresql.base.conf or if the ``custom_conf`` parameter is set. - **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
- 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`. - **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
- 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. - **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
- If there is no `custom_conf` nor `postgresql.base.conf`, the original postgresql.conf is taken and renamed to postgresql.base.conf. - **parameters**: list of configuration settings for Postgres.
- The dynamic options (with the exceptions above) are dumped into the postgresql.conf and an include is set in
postgresql.conf to the used base configuration (either postgresql.base.conf or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
The parameters would be applied in the following order (run-time are given the highest priority): - **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. Patroni ignores this parameter if ``hba_file`` PostgreSQL parameter is set to a non-default value.
1. load parameters from file `postgresql.base.conf` (or from a `custom_conf` file, if set) - **- host all all 0.0.0.0/0 md5**
2. load parameters from file `postgresql.conf` - **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
3. load parameters from file `postgresql.auto.conf`
4. run-time parameter using `-o --name=value`
This allows configuration for all the nodes (2), configuration for a specific node using `ALTER SYSTEM` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1). - **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Patroni ignores this parameter if ``ident_file`` PostgreSQL parameter is set to a non-default value.
- **- mapname1 systemname1 pguser1**
- **- mapname1 systemname2 pguser2**
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
- **host**: an address of remote node
- **port**: a port of remote node
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. 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 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.
- **plugin**: the plugin name for the logical slot.
- **ignore\_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
- **name**: the name of the replication slot.
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
- **database**: the database name (when matching a ``logical`` slot).
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
.. code:: YAML
slots:
permanent_logical_slot_name:
type: logical
database: my_db
plugin: test_decoding
permanent_physical_slot_name:
type: physical
...
ignore_slots:
- name: ignored_logical_slot_name
type: logical
database: my_db
plugin: test_decoding
- name: ignored_physical_slot_name
type: physical
...
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
...
Also, the following Patroni configuration options can be changed only dynamically: .. 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.
- ttl: 30
- loop_wait: 10
- retry_timeouts: 10
- maximum_lag_on_failover: 1048576
- max_timelines_history: 0
- check_timeline: false
- postgresql.use_slots: true
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its .. warning::
run-time values. 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.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
+56 -16
View File
@@ -10,18 +10,58 @@ To deploy a Patroni cluster without using a pre-existing PostgreSQL instance, se
Procedure Procedure
--------- ---------
A Patroni cluster can be started with a data directory from a single-node PostgreSQL database. This is achieved by following closely these steps: You can find below an overview of steps for converting an existing Postgres cluster to a Patroni managed cluster. In the steps we assume all nodes that are part of the existing cluster are currently up and running, and that you *do not* intend to change Postgres configuration while the migration is ongoing. The steps:
1. Manually start PostgreSQL daemon #. Create the Postgres users as explained for :ref:`authentication <postgresql_settings>` section of the Patroni configuration. You can find sample SQL commands to create the users in the code block below, in which you need to replace the usernames and passwords as per your environment. If you already have the relevant users, then you can skip this step.
2. Create Patroni superuser and replication users as defined in the :ref:`authentication <postgresql_settings>` section of the Patroni configuration. If this user is created in SQL, the following queries achieve this:
.. code-block:: sql .. code-block:: sql
CREATE USER $PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD '$PATRONI_SUPERUSER_PASSWORD'; -- Patroni superuser
CREATE USER $PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD '$PATRONI_REPLICATION_PASSWORD'; -- Replace PATRONI_SUPERUSER_USERNAME and PATRONI_SUPERUSER_PASSWORD accordingly
CREATE USER PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD 'PATRONI_SUPERUSER_PASSWORD';
3. Start Patroni (e.g. ``patroni /etc/patroni/patroni.yml``). It automatically detects that PostgreSQL daemon is already running but its configuration might be out-of-date. -- Patroni replication user
4. Ask Patroni to restart the node with ``patronictl restart cluster-name node-name``. This step is only required if PostgreSQL configuration is out-of-date. -- Replace PATRONI_REPLICATION_USERNAME and PATRONI_REPLICATION_PASSWORD accordingly
CREATE USER PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD 'PATRONI_REPLICATION_PASSWORD';
-- Patroni rewind user, if you intend to enable use_pg_rewind in your Patroni configuration
-- Replace PATRONI_REWIND_USERNAME and PATRONI_REWIND_PASSWORD accordingly
CREATE USER PATRONI_REWIND_USERNAME WITH ENCRYPTED PASSWORD 'PATRONI_REWIND_PASSWORD';
GRANT EXECUTE ON function pg_catalog.pg_ls_dir(text, boolean, boolean) TO PATRONI_REWIND_USERNAME;
GRANT EXECUTE ON function pg_catalog.pg_stat_file(text, boolean) TO PATRONI_REWIND_USERNAME;
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text) TO PATRONI_REWIND_USERNAME;
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text, bigint, bigint, boolean) TO PATRONI_REWIND_USERNAME;
#. Perform the following steps on all Postgres nodes. Perform all steps on one node before proceeding with the next node. Start with the primary node, then proceed with each standby node:
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
#. Create a YAML configuration file for Patroni. 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 :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 :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
-- Assume original_slot_for_member_x is the name of the slot in your original
-- cluster for replicating changes to member X, and slot_for_member_x is the
-- slot created by Patroni for that purpose. You need restart_lsn of
-- slot_for_member_x to be >= restart_lsn of original_slot_for_member_x
SELECT slot_name,
restart_lsn
FROM pg_replication_slots
WHERE slot_name IN (
'original_slot_for_member_x',
'slot_for_member_x'
)
.. _major_upgrade: .. _major_upgrade:
@@ -30,14 +70,14 @@ Major Upgrade of PostgreSQL Version
The only possible way to do a major upgrade currently is: The only possible way to do a major upgrade currently is:
1. Stop Patroni #. Stop Patroni
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node #. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
3. Update patroni.yml #. Update patroni.yml
4. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier. #. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running :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.
5. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before. #. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
6. Start Patroni on the primary node. #. Start Patroni on the primary node.
7. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes. #. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
8. Start Patroni on the standby nodes and wait for the replication to complete. #. Start Patroni on the standby nodes and wait for the replication to complete.
Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you. Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you.
+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.
+6 -6
View File
@@ -1,8 +1,8 @@
.. _ha_multi_dc: .. _ha_multi_dc:
================= ===================
HA multi datacenter HA multi datacenter
================= ===================
The high availability of a PostgreSQL cluster deployed in multiple data centers is based on replication, which can be synchronous or asynchronous (`replication_modes <replication_modes.rst>`_). The high availability of a PostgreSQL cluster deployed in multiple data centers is based on replication, which can be synchronous or asynchronous (`replication_modes <replication_modes.rst>`_).
@@ -12,7 +12,7 @@ In both cases, it is important to be clear about the following concepts:
- You should run the odd number of etcd, ZooKeeper or Consul nodes: 3 or 5! - You should run the odd number of etcd, ZooKeeper or Consul nodes: 3 or 5!
Synchronous Replication Synchronous Replication
---------------------------- -----------------------
To have a multi DC cluster that can automatically tolerate a zone drop, a minimum of 3 is required. To have a multi DC cluster that can automatically tolerate a zone drop, a minimum of 3 is required.
@@ -22,12 +22,12 @@ The architecture diagram would be the following:
We must deploy a cluster of etcd, ZooKeeper or Consul through the different DC, with a minimum of 3 nodes, one in each zone. We must deploy a cluster of etcd, ZooKeeper or Consul through the different DC, with a minimum of 3 nodes, one in each zone.
Regarding postgres, we must deploy at least 2 nodes, in different DC. Then you have to set ``synchronous_mode: true`` in the global configuration (``patronictl edit-config``). Regarding postgres, we must deploy at least 2 nodes, in different DC. Then you have to set ``synchronous_mode: true`` in the global :ref:`dynamic configuration <dynamic_configuration>`.
This enables sync replication and the primary node will choose one of the nodes as synchronous. This enables sync replication and the primary node will choose one of the nodes as synchronous.
Asynchronous Replication Asynchronous Replication
---------------------------------- ------------------------
With only two data centers it would be better to have two independent etcd clusters and run Patroni :ref:`standby cluster <standby_cluster>` in the second data center. If the first site is down, you can MANUALLY promote the ``standby_cluster``. With only two data centers it would be better to have two independent etcd clusters and run Patroni :ref:`standby cluster <standby_cluster>` in the second data center. If the first site is down, you can MANUALLY promote the ``standby_cluster``.
@@ -37,7 +37,7 @@ The architecture diagram would be the following:
Automatic promotion is not possible, because DC2 will never able to figure out the state of DC1. Automatic promotion is not possible, because DC2 will never able to figure out the state of DC1.
You should not use ``pg_ctl promote`` in this scenario, you need "manually promote" the healthy cluster with ``patronictl edit-config`` and remove ``standby_cluster`` section from there. You should not use ``pg_ctl promote`` in this scenario, you need "manually promote" the healthy cluster by removing ``standby_cluster`` section from the :ref:`dynamic configuration <dynamic_configuration>`.
.. warning:: .. warning::
If the source cluster is still up and running and you promote the standby cluster you create a split-brain. If the source cluster is still up and running and you promote the standby cluster you create a split-brain.
+25 -15
View File
@@ -6,11 +6,11 @@
Introduction Introduction
============ ============
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful. Patroni is a template for high availability (HA) PostgreSQL solutions using Python. For maximum accessibility, Patroni supports a variety of distributed configuration stores like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in datacenters — or anywhere elsewill hopefully find it useful.
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__. We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
Currently supported PostgreSQL versions: 9.3 to 15. Currently supported PostgreSQL versions: 9.3 to 16.
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the :ref:`Citus support page <citus>` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster. **Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the :ref:`Citus support page <citus>` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
@@ -22,26 +22,36 @@ Currently supported PostgreSQL versions: 9.3 to 15.
:caption: Contents: :caption: Contents:
README README
citus installation
dynamic_configuration patroni_configuration
dcs_failsafe_mode
rest_api rest_api
existing_data patronictl
ENVIRONMENT
SETTINGS
security
replica_bootstrap replica_bootstrap
replication_modes replication_modes
ha_multi_dc standby_cluster
pause
kubernetes
watchdog watchdog
pause
dcs_failsafe_mode
kubernetes
citus
existing_data
tools_integration
security
ha_multi_dc
faq
releases releases
CONTRIBUTING CONTRIBUTING
Indices and tables Indices and tables
================== ==================
* :ref:`genindex` .. ifconfig:: builder == 'html'
* :ref:`modindex`
* :ref:`search` * :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
.. ifconfig:: builder != 'html'
* :ref:`genindex`
* :ref:`search`
+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
+52
View File
@@ -32,6 +32,58 @@ Configuration
Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation. Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation.
.. _kubernetes_role_values:
Customize role label
^^^^^^^^^^^^^^^^^^^^
By default, Patroni will set corresponding labels on the pod it runs in based on node's role, such as ``role=master``.
The key and value of label can be customized by `kubernetes.role_label`, `kubernetes.leader_label_value`, `kubernetes.follower_label_value` and `kubernetes.standby_leader_label_value`.
Note that if you migrate from default role labels to custom ones, you can reduce downtime by following migration steps:
1. Add a temporary label using original role value for the pod with `kubernetes.tmp_role_label` (like ``tmp_role``). Once pods are restarted they will get following labels set by Patroni:
.. code:: YAML
labels:
cluster-name: foo
role: master
tmp_role: master
2. After all pods have been updated, modify the service selector to select the temporary label.
.. code:: YAML
selector:
cluster-name: foo
tmp_role: master
3. Add your custom role label (e.g., set `kubernetes.leader_label_value=primary`). Once pods are restarted they will get following new labels set by Patroni:
.. code:: YAML
labels:
cluster-name: foo
role: primary
tmp_role: master
4. After all pods have been updated again, modify the service selector to use new role value.
.. code:: YAML
selector:
cluster-name: foo
role: primary
5. Finally, remove the temporary label from your configuration and update all pods.
.. code:: YAML
labels:
cluster-name: foo
role: primary
Examples Examples
-------- --------
+253
View File
@@ -0,0 +1,253 @@
.. _patroni_configuration:
Patroni configuration
=====================
.. toctree::
:hidden:
dynamic_configuration
yaml_configuration
ENVIRONMENT
There are 3 types of Patroni configuration:
- Global :ref:`dynamic configuration <dynamic_configuration>`.
These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
Dynamic configuration can be set at any time using :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
have changed), a special flag ``pending_restart`` indicating this is set in the members.data JSON.
Additionally, the node status indicates this by showing ``"restart_pending": true``.
- Local :ref:`configuration file <yaml_configuration>` (patroni.yml).
These options are defined in the configuration file and take precedence over dynamic configuration.
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing :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
---------------
PostgreSQL parameters controlled by Patroni
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Some of the PostgreSQL parameters **must hold the same values on the primary and the replicas**. For those, **values set either in the local patroni configuration files or via the environment variables take no effect**. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
- **max_connections**: 100
- **max_locks_per_transaction**: 64
- **max_worker_processes**: 8
- **max_prepared_transactions**: 0
- **wal_level**: hot_standby
- **track_commit_timestamp**: off
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, **Patroni restricts setting their values to the** :ref:`dynamic configuration <dynamic_configuration>`.
- **max_wal_senders**: 5
- **max_replication_slots**: 5
- **wal_keep_segments**: 8
- **wal_keep_size**: 128MB
These parameters are validated to ensure they are sane, or meet a minimum value.
There are some other Postgres parameters controlled by Patroni:
- **listen_addresses** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- **port** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
- **cluster_name** - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
- **hot_standby: on**
- **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>`__
There also are some parameters like **postgresql.listen**, **postgresql.data_dir** that **can be set only locally**, i.e. in the Patroni :ref:`config file <yaml_configuration>` or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration.
When applying the local or dynamic configuration options, the following actions are taken:
- The node first checks if there is a `postgresql.base.conf` 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 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):
1. load parameters from file `postgresql.base.conf` (or from a ``custom_conf`` file, if set)
2. load parameters from file `postgresql.conf`
3. load parameters from file `postgresql.auto.conf`
4. run-time parameter using `-o --name=value`
This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
.. _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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Also the following Patroni configuration options **can be changed only dynamically**:
- **ttl**: 30
- **loop_wait**: 10
- **retry_timeouts**: 10
- **maximum_lag_on_failover**: 1048576
- **max_timelines_history**: 0
- **check_timeline**: false
- **postgresql.use_slots**: true
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
.. _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. - 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. - 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 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}`` One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
+786 -300
View File
File diff suppressed because it is too large Load Diff
+55 -61
View File
@@ -1,3 +1,5 @@
.. _replica_imaging_and_bootstrap:
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``. 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, 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. 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 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 ``recovery.conf`` before starting the newly bootstrapped instance (or set the recovery settings on Postgres configuration if
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``. 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. 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).
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you. 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 .. 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: .. _custom_replica_creation:
@@ -110,6 +142,24 @@ example: pgbackrest
basebackup: basebackup:
max-rate: '100M' 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 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 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 - 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. 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.
+2 -2
View File
@@ -49,11 +49,11 @@ on at least two nodes, enable ``synchronous_mode_strict`` in addition to the
``synchronous_mode``. This parameter prevents Patroni from switching off the ``synchronous_mode``. This parameter prevents Patroni from switching off the
synchronous replication on the primary when no synchronous standby candidates synchronous replication on the primary when no synchronous standby candidates
are available. As a downside, the primary is not be available for writes are available. As a downside, the primary is not be available for writes
(unless the Postgres transaction explicitly turns of ``synchronous_mode``), (unless the Postgres transaction explicitly turns off ``synchronous_mode``),
blocking all client write requests until at least one synchronous replica comes blocking all client write requests until at least one synchronous replica comes
up. up.
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. Synchronous mode can be switched on and off via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
+315 -66
View File
@@ -3,7 +3,7 @@
Patroni REST API 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 Health check endpoints
---------------------- ----------------------
@@ -30,7 +30,8 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /replica?tag_key1=value1&tag_key2=value2``: replica check endpoint. In addition, It will also check for user defined tags ``key1`` and ``key2`` and their respective values in the **tags** section of the yaml configuration management. If the tag isn't defined for an instance, or if the value in the yaml configuration doesn't match the querying value, it will return HTTP Status Code 503. - ``GET /replica?tag_key1=value1&tag_key2=value2``: replica check endpoint. In addition, It will also check for user defined tags ``key1`` and ``key2`` and their respective values in the **tags** section of the yaml configuration management. If the tag isn't defined for an instance, or if the value in the yaml configuration doesn't match the querying value, it will return HTTP Status Code 503.
In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored. In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored.
- ``GET /?tag_key1=value1&tag_key2=value2`` - ``GET /?tag_key1=value1&tag_key2=value2``
- ``GET /leader?tag_key1=value1&tag_key2=value2`` - ``GET /leader?tag_key1=value1&tag_key2=value2``
- ``GET /primary?tag_key1=value1&tag_key2=value2`` - ``GET /primary?tag_key1=value1&tag_key2=value2``
@@ -91,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. 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 .. code-block:: bash
$ curl -s http://localhost:8008/patroni | jq . $ curl -s http://localhost:8008/patroni | jq .
{ {
"state": "running", "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", "role": "master",
"server_version": 110005, "server_version": 150004,
"cluster_unlocked": false,
"xlog": { "xlog": {
"location": 25624640 "location": 67395656
}, },
"timeline": 3, "timeline": 1,
"database_system_identifier": "6739877027151648096", "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": { "patroni": {
"version": "1.6.0", "version": "3.1.0",
"scope": "batman" "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. Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` endpoint.
@@ -120,58 +283,70 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
# HELP patroni_version Patroni semver without periods. \ # HELP patroni_version Patroni semver without periods. \
# TYPE patroni_version gauge # 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. # HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.
# TYPE patroni_postgres_running gauge # 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. # HELP patroni_postmaster_start_time Epoch seconds since Postgres started.
# TYPE patroni_postmaster_start_time gauge # 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. # HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.
# TYPE patroni_master gauge # 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. # HELP patroni_xlog_location Current location of the Postgres transaction log, 0 if this node is not the leader.
# TYPE patroni_xlog_location counter # 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. # HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.
# TYPE patroni_standby_leader gauge # 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. # HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.
# TYPE patroni_replica gauge # 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. # HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.
# TYPE patroni_sync_standby gauge # TYPE patroni_sync_standby gauge
patroni_sync_standby{scope="batman"} 0 patroni_sync_standby{scope="batman",name="patroni1"} 0
# HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica. # HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica.
# TYPE patroni_xlog_received_location counter # TYPE patroni_xlog_received_location counter
patroni_xlog_received_location{scope="batman"} 0 patroni_xlog_received_location{scope="batman",name="patroni1"} 0
# HELP patroni_xlog_replayed_location Current location of the replayed Postgres transaction log, 0 if this node is not a replica. # 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 # 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. # HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed Postgres transaction log, 0 if null.
# TYPE patroni_xlog_replayed_timestamp gauge # 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. # HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.
# TYPE patroni_xlog_paused gauge # TYPE patroni_xlog_paused gauge
patroni_xlog_paused{scope="batman"} 0 patroni_xlog_paused{scope="batman",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",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",name="patroni1"} 0
# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise. # HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.
# TYPE patroni_postgres_server_version gauge # TYPE patroni_postgres_server_version gauge
patroni_postgres_server_version {scope="batman"} 140004 patroni_postgres_server_version{scope="batman",name="patroni1"} 140004
# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked. # HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.
# TYPE patroni_cluster_unlocked gauge # 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. # HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.
# TYPE patroni_postgres_timeline counter # 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. # HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully by Patroni.
# TYPE patroni_dcs_last_seen gauge # 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. # HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.
# TYPE patroni_pending_restart gauge # 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. # HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.
# TYPE patroni_is_paused gauge # TYPE patroni_is_paused gauge
patroni_is_paused{scope="batman"} 1 patroni_is_paused{scope="batman",name="patroni1"} 1
Cluster status endpoints Cluster status endpoints
@@ -185,24 +360,24 @@ Cluster status endpoints
{ {
"members": [ "members": [
{ {
"name": "postgresql0", "name": "patroni1",
"host": "127.0.0.1",
"port": 5432,
"role": "leader", "role": "leader",
"state": "running", "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, "timeline": 5,
"tags": { "tags": {
"clonefrom": true "clonefrom": true
} }
}, },
{ {
"name": "postgresql1", "name": "patroni2",
"host": "127.0.0.1",
"port": 5433,
"role": "replica", "role": "replica",
"state": "running", "state": "streaming",
"api_url": "http://127.0.0.1:8009/patroni", "api_url": "http://10.89.0.6:8008/patroni",
"host": "10.89.0.6",
"port": 5433,
"timeline": 5, "timeline": 5,
"tags": { "tags": {
"clonefrom": true "clonefrom": true
@@ -210,9 +385,11 @@ Cluster status endpoints
"lag": 0 "lag": 0
} }
], ],
"scope": "demo",
"scheduled_switchover": { "scheduled_switchover": {
"at": "2019-09-24T10:36:00+02:00", "at": "2023-09-24T10:36:00+02:00",
"from": "postgresql0" "from": "patroni1",
"to": "patroni3"
} }
} }
@@ -249,6 +426,7 @@ Cluster status endpoints
] ]
] ]
.. _config_endpoint:
Config endpoint Config endpoint
--------------- ---------------
@@ -257,7 +435,7 @@ Config endpoint
.. code-block:: bash .. code-block:: bash
$ curl -s localhost:8008/config | jq . $ curl -s http://localhost:8008/config | jq .
{ {
"ttl": 30, "ttl": 30,
"loop_wait": 10, "loop_wait": 10,
@@ -268,7 +446,6 @@ Config endpoint
"use_pg_rewind": true, "use_pg_rewind": true,
"parameters": { "parameters": {
"hot_standby": "on", "hot_standby": "on",
"wal_log_hints": "on",
"wal_level": "hot_standby", "wal_level": "hot_standby",
"max_wal_senders": 5, "max_wal_senders": 5,
"max_replication_slots": 5, "max_replication_slots": 5,
@@ -295,7 +472,6 @@ Config endpoint
"use_pg_rewind": true, "use_pg_rewind": true,
"parameters": { "parameters": {
"hot_standby": "on", "hot_standby": "on",
"wal_log_hints": "on",
"wal_level": "hot_standby", "wal_level": "hot_standby",
"max_wal_senders": 5, "max_wal_senders": 5,
"max_replication_slots": 5, "max_replication_slots": 5,
@@ -319,8 +495,9 @@ Let's check that the node processed this configuration. First of all it should s
"location": 2197818976 "location": 2197818976
}, },
"patroni": { "patroni": {
"version": "1.0",
"scope": "batman", "scope": "batman",
"version": "1.0" "name": "patroni1"
}, },
"state": "running", "state": "running",
"role": "master", "role": "master",
@@ -348,7 +525,6 @@ If you want to remove (reset) some setting just patch it with ``null``:
"hot_standby": "on", "hot_standby": "on",
"unix_socket_directories": ".", "unix_socket_directories": ".",
"wal_level": "hot_standby", "wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5, "max_wal_senders": 5,
"max_replication_slots": 5 "max_replication_slots": 5
} }
@@ -362,7 +538,7 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
.. code-block:: bash .. code-block:: bash
$ curl -s -XPUT -d \ $ 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 . http://localhost:8008/config | jq .
{ {
"ttl": 20, "ttl": 20,
@@ -374,7 +550,6 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
"hot_standby": "on", "hot_standby": "on",
"unix_socket_directories": ".", "unix_socket_directories": ".",
"wal_level": "hot_standby", "wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5 "max_wal_senders": 5
}, },
"use_pg_rewind": true "use_pg_rewind": true
@@ -386,41 +561,114 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
Switchover and failover endpoints 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 .. code-block:: bash
$ curl -s http://localhost:8009/failover -XPOST -d '{"candidate":"postgresql1"}' $ curl -s http://localhost:8008/switchover -XPOST -d '{"leader":"postgresql1"}'
Successfully failed over to "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 .. code-block:: bash
$ curl -s http://localhost:8008/switchover -XPOST -d \ $ curl -s http://localhost:8008/switchover -XPOST -d \
'{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}' '{"leader":"postgresql1","candidate":"postgresql2"}'
Switchover scheduled 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. $ curl -s http://localhost:8008/switchover -XPOST -d \
The ``DELETE /switchover`` is used by ``patronictl flush <cluster-name> switchover``. '{"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 Restart endpoint
---------------- ----------------
@@ -434,15 +682,16 @@ Restart endpoint
- ``DELETE /restart``: delete the scheduled restart - ``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 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 Reinitialize endpoint
@@ -452,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 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`.
+4 -4
View File
@@ -9,11 +9,11 @@ A Patroni cluster has two interfaces to be protected from unauthorized access: t
Protecting DCS 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. Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`SETTINGS <bootstrap_settings>` The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`settings <yaml_configuration>`.
The general recommendation is to enable TLS for all DCS communication. The general recommendation is to enable TLS for all DCS communication.
@@ -22,7 +22,7 @@ Protecting the REST API
Protecting the REST API is a more complicated task. 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. 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 ``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 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.
+414
View File
@@ -0,0 +1,414 @@
.. _yaml_configuration:
============================
YAML Configuration Settings
============================
Global/Universal
----------------
- **name**: the name of the host. Must be unique for the cluster.
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
.. _log_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. 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.
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **loggers**: This section allows redefining logging level per python module
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
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 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.
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
parameter is present in the configuration file.
- **initdb**: (optional) list options to be passed on to initdb.
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _citus_settings:
Citus
-----
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
- **group**: the Citus group id, integer. Use ``0`` for coordinator and ``1``, ``2``, etc... for workers
- **database**: the database where ``citus`` extension should be created. Must be the same on the coordinator and all workers. Currently only one database is supported.
.. _consul_settings:
Consul
------
Most of the parameters are optional, but you have to specify one of the **host** or **url**
- **host**: the host:port for the Consul local agent.
- **url**: url for the Consul local agent, in format: http(s)://host:port.
- **port**: (optional) Consul port.
- **scheme**: (optional) **http** or **https**, defaults to **http**.
- **token**: (optional) ACL token.
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**.
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``primary``/``replica``/``standby-leader``). By default an empty list is used.
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
The ``token`` needs to have the following ACL permissions:
::
service_prefix "${scope}" {
policy = "write"
}
key_prefix "${namespace}/${scope}" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
Etcd
----
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
- **host**: the host:port for the etcd endpoint.
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
- **url**: url for the etcd.
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
- **username**: (optional) username for etcd authentication.
- **password**: (optional) password for etcd authentication.
- **cacert**: (optional) The ca certificate. If present it will enable validation.
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
Etcdv3
------
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
.. warning::
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
ZooKeeper
----------
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
- **cacert**: (optional) The CA certificate. If present it will enable validation.
- **cert**: (optional) File with the client certificate.
- **key**: (optional) File with the client key.
- **key_password**: (optional) The client key password.
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
- **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.
Exhibitor
---------
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor.
- **port**: Exhibitor port.
.. _kubernetes_settings:
Kubernetes
----------
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
- **role\_label**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **leader\_label\_value**: (optional) value of the pod label when Postgres role is ``master``. Default value is ``master``.
- **follower\_label\_value**: (optional) value of the pod label when Postgres role is ``replica``. Default value is ``replica``.
- **standby\_leader\_label\_value**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``master``.
- **tmp_\role\_label**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
- **retriable\_http\_codes**: (optional) list of HTTP status codes from K8s API to retry on. By default Patroni is retrying on ``500``, ``503``, and ``504``, or if K8s API response has ``retry-after`` HTTP header.
.. _raft_settings:
Raft (deprecated)
-----------------
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
Short FAQ about Raft implementation
- Q: How to list all the nodes providing consensus?
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
- Q: Where to get the ``syncobj_admin`` utility?
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
- Q: it is possible to run Patroni node without adding in to the consensus?
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
.. _postgresql_settings:
PostgreSQL
----------
- **postgresql**:
- **authentication**:
- **superuser**:
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
- **password**: password for the superuser, set during initialization (initdb).
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **replication**:
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
- **password**: replication password; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **rewind**:
- **username**: (optional) name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **password**: (optional) password for the user for ``pg_rewind``; the user will be created during initialization.
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
- **on\_reload**: run this script when configuration reload is triggered.
- **on\_restart**: run this script when the postgres restarts (without changing role).
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
- **on\_start**: run this script when the postgres starts.
- **on\_stop**: run this script when the postgres stops.
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
- **bin\_dir**: (optional) Path to PostgreSQL binaries (pg_ctl, initdb, pg_controldata, pg_basebackup, postgres, pg_isready, pg_rewind). If not provided or is an empty string, PATH environment variable will be used to find the executables.
- **bin\_name**: (optional) Make it possible to override Postgres binary names, if you are using a custom Postgres distribution:
- **pg\_ctl**: (optional) Custom name for ``pg_ctl`` binary.
- **initdb**: (optional) Custom name for ``initdb`` binary.
- **pg\controldata**: (optional) Custom name for ``pg_controldata`` binary.
- **pg\_basebackup**: (optional) Custom name for ``pg_basebackup`` binary.
- **postgres**: (optional) Custom name for ``postgres`` binary.
- **pg\_isready**: (optional) Custom name for ``pg_isready`` binary.
- **pg\_rewind**: (optional) Custom name for ``pg_rewind`` binary.
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <patroni_configuration>` for details.
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. Patroni ignores this parameter if ``hba_file`` PostgreSQL parameter is set to a non-default value. Together with :ref:`dynamic configuration <dynamic_configuration>` this parameter simplifies management of ``pg_hba.conf``.
- **- host all all 0.0.0.0/0 md5**
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Patroni ignores this parameter if ``ident_file`` PostgreSQL parameter is set to a non-default value. Together with :ref:`dynamic configuration <dynamic_configuration>` this parameter simplifies management of ``pg_ident.conf``.
- **- mapname1 systemname1 pguser1**
- **- mapname1 systemname2 pguser2**
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
- **before\_stop**: a script that executes immediately prior to stopping postgres. As opposed to a callback, this script runs synchronously, blocking shutdown until it has completed. The return code of this script does not impact whether shutdown proceeds afterwards.
.. _restapi_settings:
REST API
--------
- **restapi**:
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
- **authentication**: (optional)
- **username**: Basic-auth username to protect unsafe REST API endpoints.
- **password**: Basic-auth password to protect unsafe REST API endpoints.
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
- **request_queue_size**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
Here is an example of both **http_extra_headers** and **https_extra_headers**:
.. code:: YAML
restapi:
listen: <listen>
connect_address: <connect_address>
authentication:
username: <username>
password: <password>
http_extra_headers:
'X-Frame-Options': 'SAMEORIGIN'
'X-XSS-Protection': '1; mode=block'
'X-Content-Type-Options': 'nosniff'
cafile: <ca file>
certfile: <cert>
keyfile: <key>
https_extra_headers:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
.. warning::
- The ``restapi.connect_address`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
- If you enabled client certificates validation (``restapi.verify_client`` is set to ``required``), you also **must** provide **valid client certificates** in the ``ctl.certfile``, ``ctl.keyfile``, ``ctl.keyfile_password``. If not provided, Patroni will not work correctly.
.. _patronictl_settings:
CTL
---
- **ctl**: (optional)
- **authentication**:
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided :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 :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.
Watchdog
--------
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
.. _tags_settings:
Tags
----
- **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:
- **key1**: ``true``
- **key2**: ``false``
- **key3**: ``1.4``
- **key4**: ``"RandomString"``
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.
+97
View File
@@ -0,0 +1,97 @@
# syntax = docker/dockerfile:1.5
# Used only for running tests using tox, see ../tox.ini
ARG PG_MAJOR
ARG PGHOME=/home/postgres
ARG LC_ALL=C.UTF-8
ARG LANG=C.UTF-8
ARG BASE_IMAGE=postgres
FROM ${BASE_IMAGE}:${PG_MAJOR}
ARG PGHOME
ARG LC_ALL
ARG LANG
ENV PGHOME="$PGHOME"
ENV PG_USER="${PG_USER:-postgres}"
ENV PG_GROUP="${PG_GROUP:-$PG_USER}"
ENV LC_ALL="$LC_ALL"
ENV LANG="$LANG"
ARG ETCDVERSION=3.3.13
ENV ETCDVERSION="$ETCDVERSION"
ARG ETCDURL="https://github.com/coreos/etcd/releases/download/v$ETCDVERSION"
USER root
RUN set -ex \
&& apt-get update \
&& apt-get reinstall init-system-helpers \
&& apt-get install -y \
python3-dev \
python3-venv \
rsync \
curl \
gcc \
golang \
jq \
locales \
sudo \
busybox \
net-tools \
iputils-ping \
&& rm -rf /var/cache/apt \
\
&& 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 \
&& chown -R "$PG_USER:$PG_GROUP" /var/log /home/postgres \
\
# Download etcd \
&& curl -sL "$ETCDURL/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
ENV PATH="/tox/bin:$PATH"
# This Dockerfile syntax only works with docker buildx and the syntax
# line at the top of this file.
COPY <<EOF /tox-wrapper.sh
#!/usr/bin/env bash
set -ex
copy_output() {
if [[ -d "\$PGHOME/src/features/output" && /src/features ]] ;then
cp -a "\$PGHOME/src/features/output" "/src/features/output-\$HOSTNAME"
find "/src/features/output-\$HOSTNAME" -type f -exec chmod 666 {} \\;
find "/src/features/output-\$HOSTNAME" -type d -exec chmod 777 {} \\;
fi
}
# Ensure the copy is ran if the container is stopped with `docker stop` or `docker kill`
trap 'copy_output' SIGTERM
# For architectures such as aarch we need to get the respective GOARCH
# so we can tell etcd we're ok with running an unsupported architecture.
export ETCD_UNSUPPORTED_ARCH=$(go env GOARCH)
cd /src
runuser -u "\$PG_USER" -- \\
find . ! -readable 2>/dev/null \\
| sed 's|^./||' >/tmp/copy_exclude.lst \\
|| true
runuser -u "\$PG_USER" -- \\
rsync -a \\
--exclude=.tox \\
--exclude="features/output*" \\
--exclude-from="/tmp/copy_exclude.lst" \\
. "\$PGHOME/src/"
cd "\$PGHOME/src"
runuser -u "\$PG_USER" -w ETCD_UNSUPPORTED_ARCH -- "\$@" &
wait $!
# SIGINT whilst child proc is running is not seen by trap so we run a copy here instead of using
# trap copy_output SIGINT EXIT
copy_output
EOF
RUN chmod +x /tox-wrapper.sh
VOLUME /src
ENTRYPOINT ["/tox-wrapper.sh"]
+1
View File
@@ -6,6 +6,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--datadir", required=True) parser.add_argument("--datadir", required=True)
parser.add_argument("--sourcedir", required=True) parser.add_argument("--sourcedir", required=True)
parser.add_argument("--test-argument", required=True)
args, _ = parser.parse_known_args() args, _ = parser.parse_known_args()
shutil.copytree(args.sourcedir, args.datadir) shutil.copytree(args.sourcedir, args.datadir)
+6 -6
View File
@@ -72,14 +72,14 @@ Feature: basic replication
Then table bar is present on postgres1 after 20 seconds Then table bar is present on postgres1 after 20 seconds
And Response on GET http://127.0.0.1:8010/config contains master_start_timeout after 10 seconds And Response on GET http://127.0.0.1:8010/config contains master_start_timeout after 10 seconds
Scenario: check immediate failover when master_start_timeout=0
Given I kill postmaster on postgres2
Then postgres1 is a leader after 10 seconds
And postgres1 role is the primary after 10 seconds
Scenario: check rejoin of the former primary with pg_rewind Scenario: check rejoin of the former primary with pg_rewind
Given I add the table splitbrain to postgres0 Given I add the table splitbrain to postgres0
And I start postgres0 And I start postgres0
Then postgres0 role is the secondary after 20 seconds Then postgres0 role is the secondary after 20 seconds
When I add the table buz to postgres1 When I add the table buz to postgres2
Then table buz is present on postgres0 after 20 seconds Then table buz is present on postgres0 after 20 seconds
@reject-duplicate-name
Scenario: check graceful rejection when two nodes have the same name
Given I start duplicate postgres0 on port 8011
Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds
+12 -11
View File
@@ -10,20 +10,21 @@ Feature: citus
And I start postgres3 in citus group 1 And I start postgres3 in citus group 1
Then replication works from postgres0 to postgres1 after 15 seconds Then replication works from postgres0 to postgres1 after 15 seconds
Then replication works from postgres2 to postgres3 after 15 seconds Then replication works from postgres2 to postgres3 after 15 seconds
And postgres0 is registered in the postgres0 as the worker in group 0 And postgres0 is registered in the postgres0 as the primary in group 0 after 5 seconds
And postgres2 is registered in the postgres0 as the worker in group 1 And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
Scenario: coordinator failover updates pg_dist_node Scenario: coordinator failover updates pg_dist_node
Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force
Then postgres1 role is the primary after 10 seconds Then postgres1 role is the primary after 10 seconds
And "members/postgres0" key in a group 0 in DCS has state=running after 15 seconds
And replication works from postgres1 to postgres0 after 15 seconds And replication works from postgres1 to postgres0 after 15 seconds
And postgres1 is registered in the postgres2 as the primary in group 0 after 5 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds
And postgres1 is registered in the postgres2 as the worker in group 0 When I run patronictl.py switchover batman --group 0 --candidate postgres0 --force
When I run patronictl.py failover batman --group 0 --candidate postgres0 --force
Then postgres0 role is the primary after 10 seconds Then postgres0 role is the primary after 10 seconds
And replication works from postgres0 to postgres1 after 15 seconds And replication works from postgres0 to postgres1 after 15 seconds
And postgres0 is registered in the postgres2 as the primary in group 0 after 5 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds
And postgres0 is registered in the postgres2 as the worker in group 0
Scenario: worker switchover doesn't break client queries on the coordinator Scenario: worker switchover doesn't break client queries on the coordinator
Given I create a distributed table on postgres0 Given I create a distributed table on postgres0
@@ -31,16 +32,17 @@ Feature: citus
When I run patronictl.py switchover batman --group 1 --force When I run patronictl.py switchover batman --group 1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres3 role is the primary after 10 seconds And postgres3 role is the primary after 10 seconds
And "members/postgres2" key in a group 1 in DCS has state=running after 15 seconds
And replication works from postgres3 to postgres2 after 15 seconds And replication works from postgres3 to postgres2 after 15 seconds
And postgres3 is registered in the postgres0 as the primary in group 1 after 5 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds
And postgres3 is registered in the postgres0 as the worker in group 1
And a thread is still alive And a thread is still alive
When I run patronictl.py switchover batman --group 1 --force When I run patronictl.py switchover batman --group 1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres2 role is the primary after 10 seconds And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the worker in group 1
And a thread is still alive And a thread is still alive
When I stop a thread When I stop a thread
Then a distributed table on postgres0 has expected rows Then a distributed table on postgres0 has expected rows
@@ -52,7 +54,7 @@ Feature: citus
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres2 role is the primary after 10 seconds And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the worker in group 1 And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And a thread is still alive And a thread is still alive
When I stop a thread When I stop a thread
Then a distributed table on postgres0 has expected rows Then a distributed table on postgres0 has expected rows
@@ -64,9 +66,8 @@ Feature: citus
When I run patronictl.py edit-config batman --group 2 -s ttl=20 --force When I run patronictl.py edit-config batman --group 2 -s ttl=20 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And I receive a response output "+ttl: 20" And I receive a response output "+ttl: 20"
When I sleep for 2 seconds Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds
Then postgres4 is registered in the postgres2 as the worker in group 2
When I shut down postgres4 When I shut down postgres4
Then There is a transaction in progress on postgres0 changing pg_dist_node Then there is a transaction in progress on postgres0 changing pg_dist_node after 5 seconds
When I run patronictl.py restart batman postgres2 --group 1 --force When I run patronictl.py restart batman postgres2 --group 1 --force
Then a transaction finishes in 20 seconds 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 Scenario: check failsafe mode can be successfully enabled
Given I start postgres0 Given I start postgres0
And postgres0 is a leader after 10 seconds And postgres0 is a leader after 10 seconds
And I sleep for 3 seconds 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": 5, "failsafe_mode": true} 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 Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/failsafe contains postgres0 after 10 seconds 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 When I issue a GET request to http://127.0.0.1:8008/failsafe
Then I receive a response code 200 Then I receive a response code 200
And I receive a response postgres0 http://127.0.0.1:8008/patroni 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 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"}}} 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 Then I receive a response code 200
@@ -28,7 +28,6 @@ Feature: dcs failsafe mode
When I do a backup of postgres0 When I do a backup of postgres0
And I shut down postgres0 And I shut down postgres0
When I start postgres1 in a cluster batman from backup with no_leader 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 Then postgres1 role is the replica after 12 seconds
Scenario: check leader and replica are both in /failsafe key after leader is back Scenario: check leader and replica are both in /failsafe key after leader is back
@@ -45,41 +44,73 @@ Feature: dcs failsafe mode
@dcs-failsafe @dcs-failsafe
@slot-advance @slot-advance
Scenario: check leader and replica are functioning while DCS is down 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 And 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:8008/primary contains failsafe_mode_is_active after 12 seconds
Then postgres0 role is the primary after 10 seconds Then postgres0 role is the primary after 10 seconds
And postgres1 role is the replica after 2 seconds And postgres1 role is the replica after 2 seconds
And replication works from postgres0 to postgres1 after 10 seconds And replication works from postgres0 to postgres1 after 10 seconds
And I get all changes from logical slot dcs_slot_0 on postgres0 When 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 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 @dcs-failsafe
Scenario: check primary is demoted when one replica is shut down and DCS is down Scenario: check primary is demoted when one replica is shut down and DCS is down
Given DCS is down Given DCS is down
And I kill postgres1 And I kill postgres1
And I kill postmaster on postgres1 And I kill postmaster on postgres1
And I sleep for 2 seconds
Then postgres0 role is the replica after 12 seconds Then postgres0 role is the replica after 12 seconds
@dcs-failsafe @dcs-failsafe
Scenario: check known replica is promoted when leader is down and DCS is up 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 And DCS is up
When I start postgres1 When I start postgres1
Then "members/postgres1" key in DCS has state=running after 10 seconds Then "members/postgres1" key in DCS has state=running after 10 seconds
And postgres1 role is the primary after 25 seconds And postgres1 role is the primary after 25 seconds
@dcs-failsafe @dcs-failsafe
Scenario: check three-node cluster is functioning while DCS is down Scenario: scale to three-node cluster
Given I start postgres0 Given I start postgres0
And I start postgres2 And I start postgres2
Then "members/postgres2" key in DCS has state=running after 10 seconds 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 "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 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 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 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 Then postgres1 role is the primary after 10 seconds
And postgres0 role is the replica after 2 seconds And postgres0 role is the replica after 2 seconds
And postgres2 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
+66 -40
View File
@@ -7,6 +7,7 @@ import psutil
import re import re
import shutil import shutil
import signal import signal
import stat
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -51,15 +52,14 @@ class AbstractController(abc.ABC):
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a') self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
self._handle = self._start() self._handle = self._start()
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
max_wait_limit *= self._context.timeout_multiplier max_wait_limit *= self._context.timeout_multiplier
for _ in range(max_wait_limit): for _ in range(max_wait_limit):
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
if self._is_accessible(): if self._is_accessible():
break break
time.sleep(1) time.sleep(1)
else: else:
assert False,\ assert False, \
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit) "{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
def stop(self, kill=False, timeout=15, _=False): def stop(self, kill=False, timeout=15, _=False):
@@ -162,9 +162,10 @@ class PatroniController(AbstractController):
def stop(self, kill=False, timeout=15, postgres=False): def stop(self, kill=False, timeout=15, postgres=False):
if postgres: 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) 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:]) self._context.dcs_ctl.delete_pod(self._name[8:])
if self.watchdog: if self.watchdog:
self.watchdog.stop() self.watchdog.stop()
@@ -191,7 +192,7 @@ class PatroniController(AbstractController):
config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']} config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']}
host = config['restapi']['listen'].rsplit(':', 1)[0] host = config['restapi']['listen'].rsplit(':', 1)[0]
config['restapi']['listen'] = config['restapi']['connect_address'] = '{0}:{1}'.format(host, 8008+int(name[-1])) config['restapi']['listen'] = config['restapi']['connect_address'] = '{}:{}'.format(host, 8008 + int(name[-1]))
host = config['postgresql']['listen'].rsplit(':', 1)[0] host = config['postgresql']['listen'].rsplit(':', 1)[0]
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT) config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
@@ -244,6 +245,10 @@ class PatroniController(AbstractController):
self.recursive_update(config, custom_config) self.recursive_update(config, custom_config)
self.recursive_update(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': { 'bootstrap': {
'dcs': { 'dcs': {
'loop_wait': 2, 'loop_wait': 2,
@@ -251,10 +256,18 @@ class PatroniController(AbstractController):
'parameters': { 'parameters': {
'wal_keep_segments': 100, 'wal_keep_segments': 100,
'archive_mode': 'on', 'archive_mode': 'on',
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT + 'archive_command':
' --mode archive ' + (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
'--dirname {} --filename %f --pathname %p').format( + ' --mode archive '
os.path.join(self._work_directory, 'data', 'wal_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('\\', '/')
} }
} }
} }
@@ -343,6 +356,13 @@ class PatroniController(AbstractController):
'--datadir=' + os.path.join(self._work_directory, dest), '--datadir=' + os.path.join(self._work_directory, dest),
'--dbname=' + self.backup_source]) '--dbname=' + self.backup_source])
def read_patroni_log(self, level):
try:
with open(str(os.path.join(self._output_dir or '', self._name + ".log"))) as f:
return [line for line in f.readlines() if line[24:24 + len(level)] == level]
except IOError:
return []
class ProcessHang(object): class ProcessHang(object):
@@ -594,7 +614,7 @@ class KubernetesController(AbstractExternalDcsController):
api_process = 'kube-apiserver' api_process = 'kube-apiserver'
elif context.startswith('k3d-'): elif context.startswith('k3d-'):
container = '{0}-server-0'.format(context) container = '{0}-server-0'.format(context)
api_process = 'k3s' api_process = 'k3s server'
else: else:
return super(KubernetesController, self)._is_running() return super(KubernetesController, self)._is_running()
try: try:
@@ -642,9 +662,10 @@ class KubernetesController(AbstractExternalDcsController):
try: try:
if group is not None: if group is not None:
scope = '{0}-{1}'.format(scope, group) 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) e = self._api.read_namespaced_endpoints(ep, self._namespace)
if key != 'sync': if key not in ('sync', 'status', 'failsafe'):
return e.metadata.annotations[key] return e.metadata.annotations[key]
else: else:
return json.dumps(e.metadata.annotations) return json.dumps(e.metadata.annotations)
@@ -680,7 +701,7 @@ class ZooKeeperController(AbstractExternalDcsController):
self._client = kazoo.client.KazooClient() self._client = kazoo.client.KazooClient()
def process_name(self): def process_name(self):
return "zookeeper" return "java .*zookeeper"
def query(self, key, scope='batman', group=None): def query(self, key, scope='batman', group=None):
import kazoo.exceptions import kazoo.exceptions
@@ -801,7 +822,7 @@ class PatroniPoolController(object):
raise Exception # this one should never happen because the previous line will always raise and exception raise Exception # this one should never happen because the previous line will always raise and exception
except Exception as e: except Exception as e:
self._context.postgres_supports_ssl = isinstance(e, subprocess.CalledProcessError)\ self._context.postgres_supports_ssl = isinstance(e, subprocess.CalledProcessError)\
and 'SSL is not supported by this build' not in e.output.decode() and 'SSL is not supported by this build' not in e.output.decode()
@property @property
def patroni_path(self): def patroni_path(self):
@@ -826,7 +847,7 @@ class PatroniPoolController(object):
def __getattr__(self, func): def __getattr__(self, func):
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to', if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to',
'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup']: 'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup', 'read_patroni_log']:
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func)) raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
def wrapper(name, *args, **kwargs): def wrapper(name, *args, **kwargs):
@@ -853,8 +874,8 @@ class PatroniPoolController(object):
'bootstrap': { 'bootstrap': {
'method': 'pg_basebackup', 'method': 'pg_basebackup',
'pg_basebackup': { 'pg_basebackup': {
'command': " ".join(self.BACKUP_SCRIPT + 'command': " ".join(self.BACKUP_SCRIPT
['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)]) + ['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)])
}, },
'dcs': { 'dcs': {
'postgresql': { 'postgresql': {
@@ -867,9 +888,9 @@ class PatroniPoolController(object):
'postgresql': { 'postgresql': {
'parameters': { 'parameters': {
'archive_mode': 'on', 'archive_mode': 'on',
'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive ' + 'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive '
'--dirname {} --filename %f --pathname %p').format( + '--dirname {} --filename %f --pathname %p')
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/')) .format(os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
}, },
'authentication': { 'authentication': {
'superuser': {'password': 'zalando1'}, 'superuser': {'password': 'zalando1'},
@@ -879,22 +900,28 @@ class PatroniPoolController(object):
} }
self.start(to_name, custom_config=custom_config) 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): def bootstrap_from_backup(self, name, cluster_name):
custom_config = { custom_config = {
'scope': cluster_name, 'scope': cluster_name,
'bootstrap': { 'bootstrap': {
'method': 'backup_restore', 'method': 'backup_restore',
'backup_restore': { 'backup_restore': self.backup_restore_config({
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' +
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'recovery_conf': { 'recovery_conf': {
'recovery_target_action': 'promote', 'recovery_target_action': 'promote',
'recovery_target_timeline': 'latest', 'recovery_target_timeline': 'latest',
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' + 'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
'--dirname {} --filename %f --pathname %p').format( + '--dirname {} --filename %f --pathname %p').format(
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/')) os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
} },
} })
}, },
'postgresql': { 'postgresql': {
'authentication': { 'authentication': {
@@ -909,17 +936,8 @@ class PatroniPoolController(object):
custom_config = { custom_config = {
'scope': cluster_name, 'scope': cluster_name,
'postgresql': { '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'], 'create_replica_methods': ['no_leader_bootstrap'],
'no_leader_bootstrap': { 'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' +
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'no_leader': '1'
}
} }
} }
self.start(name, custom_config=custom_config) self.start(name, custom_config=custom_config)
@@ -1058,11 +1076,15 @@ def before_all(context):
context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key') context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key')
context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt') context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt')
try: try:
if sys.platform == 'darwin' and 'GITHUB_ACTIONS' in os.environ:
raise Exception
with open(os.devnull, 'w') as null: with open(os.devnull, 'w') as null:
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni', ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
'-keyout', context.keyfile, '-out', context.certfile], stdout=null, stderr=null) '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', context.keyfile,
'-out', context.certfile], stdout=null, stderr=null)
if ret != 0: if ret != 0:
raise Exception raise Exception
os.chmod(context.keyfile, stat.S_IWRITE | stat.S_IREAD)
except Exception: except Exception:
context.keyfile = context.certfile = None context.keyfile = context.certfile = None
@@ -1073,7 +1095,9 @@ def before_all(context):
'PATRONI_RESTAPI_CERTFILE': context.certfile, 'PATRONI_RESTAPI_CERTFILE': context.certfile,
'PATRONI_RESTAPI_KEYFILE': context.keyfile, 'PATRONI_RESTAPI_KEYFILE': context.keyfile,
'PATRONI_RESTAPI_VERIFY_CLIENT': 'required', 'PATRONI_RESTAPI_VERIFY_CLIENT': 'required',
'PATRONI_CTL_INSECURE': 'on'}) 'PATRONI_CTL_INSECURE': 'on',
'PATRONI_CTL_CERTFILE': context.certfile,
'PATRONI_CTL_KEYFILE': context.keyfile})
ctl.update({'cacert': context.certfile, 'certfile': context.certfile, 'keyfile': context.keyfile}) ctl.update({'cacert': context.certfile, 'certfile': context.certfile, 'keyfile': context.keyfile})
context.request_executor = PatroniRequest({'ctl': ctl}, True) context.request_executor = PatroniRequest({'ctl': ctl}, True)
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context) context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
@@ -1135,3 +1159,5 @@ def before_scenario(context, scenario):
break break
if 'dcs-failsafe' in scenario.effective_tags and not context.dcs_ctl._handle: if 'dcs-failsafe' in scenario.effective_tags and not context.dcs_ctl._handle:
scenario.skip('it is not possible to control state of {0} from tests'.format(context.dcs_ctl.name())) scenario.skip('it is not possible to control state of {0} from tests'.format(context.dcs_ctl.name()))
if 'reject-duplicate-name' in scenario.effective_tags and context.dcs_ctl.name() == 'raft':
scenario.skip('Flaky test with Raft')
+18 -18
View File
@@ -10,7 +10,7 @@ Feature: ignored slots
When I shut down postgres1 When I shut down postgres1
And I start postgres1 And I start postgres1
Then postgres1 is a leader after 10 seconds Then postgres1 is a leader after 10 seconds
And "members/postgres1" key in DCS has role=master after 3 seconds And "members/postgres1" key in DCS has role=master after 10 seconds
# Make sure Patroni has finished telling Postgres it should be accepting writes. # Make sure Patroni has finished telling Postgres it should be accepting writes.
And postgres1 role is the primary after 20 seconds And postgres1 role is the primary after 20 seconds
# 1. Create our test logical replication slot. # 1. Create our test logical replication slot.
@@ -25,37 +25,37 @@ Feature: ignored slots
# but Patroni can actually end up dropping them almost immediately, so it's helpful # 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 # to verify they exist before we begin testing whether they persist through failover
# cycles. # cycles.
Then postgres1 has a logical replication slot named unmanaged_slot_0 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 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 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 And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
When I start postgres0 When I start postgres0
Then "members/postgres0" key in DCS has role=replica after 3 seconds Then "members/postgres0" key in DCS has role=replica after 10 seconds
And postgres0 role is the secondary after 20 seconds And postgres0 role is the secondary after 20 seconds
# Verify that the replica has advanced beyond the point in the WAL # Verify that the replica has advanced beyond the point in the WAL
# where we created the replication slot so that on the next failover # where we created the replication slot so that on the next failover
# cycle we don't accidentally rewind to before the slot creation. # cycle we don't accidentally rewind to before the slot creation.
And replication works from postgres1 to postgres0 after 20 seconds And replication works from postgres1 to postgres0 after 20 seconds
When I shut down postgres1 When I shut down postgres1
Then "members/postgres0" key in DCS has role=master after 3 seconds Then "members/postgres0" key in DCS has role=master after 10 seconds
# 2. After a failover the server (now a replica) still has the slot. # 2. After a failover the server (now a replica) still has the slot.
When I start postgres1 When I start postgres1
Then postgres1 role is the secondary after 20 seconds Then postgres1 role is the secondary after 20 seconds
And "members/postgres1" key in DCS has role=replica after 3 seconds And "members/postgres1" key in DCS has role=replica after 10 seconds
# give Patroni time to sync replication slots # give Patroni time to sync replication slots
And I sleep for 2 seconds And I sleep for 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
And postgres1 does not have a logical replication slot named dummy_slot And postgres1 does not have a replication slot named dummy_slot
# 3. After a failover the server (now a primary) still has the slot. # 3. After a failover the server (now a primary) still has the slot.
When I shut down postgres0 When I shut down postgres0
Then "members/postgres1" key in DCS has role=master after 3 seconds Then "members/postgres1" key in DCS has role=master after 10 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin 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
+15 -14
View File
@@ -35,30 +35,30 @@ Scenario: check local configuration reload
Then I receive a response code 202 Then I receive a response code 202
Scenario: check dynamic configuration change via DCS Scenario: check dynamic configuration change via DCS
Given I run patronictl.py edit-config -s 'ttl=10' -p 'max_connections=101' --force batman Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "postgresql": {"parameters": {"max_connections": "101"}}}
Then I receive a response returncode 0 Then I receive a response code 200
And I receive a response output "+ttl: 10"
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds
When I issue a GET request to http://127.0.0.1:8008/config When I issue a GET request to http://127.0.0.1:8008/config
Then I receive a response code 200 Then I receive a response code 200
And I receive a response ttl 10 And I receive a response ttl 20
When I issue a GET request to http://127.0.0.1:8008/patroni When I issue a GET request to http://127.0.0.1:8008/patroni
Then I receive a response code 200 Then I receive a response code 200
And I receive a response tags {'new_tag': 'new_value'} And I receive a response tags {'new_tag': 'new_value'}
And I sleep for 4 seconds And I sleep for 4 seconds
Scenario: check the scheduled restart Scenario: check the scheduled restart
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}} Given I run patronictl.py edit-config -p 'superuser_reserved_connections=6' --force batman
Then I receive a response code 200 Then I receive a response returncode 0
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds And I receive a response output "+ superuser_reserved_connections: 6"
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"} Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"}
Then I receive a response code 202 Then I receive a response code 202
And I sleep for 8 seconds And I sleep for 8 seconds
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"} Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"}
Then I receive a response code 202 Then I receive a response code 202
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
And postgres0 role is the primary after 10 seconds And postgres0 role is the primary after 10 seconds
Scenario: check API requests for the primary-replica pair in the pause mode Scenario: check API requests for the primary-replica pair in the pause mode
Given I start postgres1 Given I start postgres1
@@ -68,6 +68,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
When I kill postmaster on postgres1 When I kill postmaster on postgres1
And I issue a GET request to http://127.0.0.1:8009/replica And I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 503 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 When I run patronictl.py restart batman postgres1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
Then replication works from postgres0 to postgres1 after 20 seconds Then replication works from postgres0 to postgres1 after 20 seconds
@@ -76,15 +77,15 @@ Scenario: check API requests for the primary-replica pair in the pause mode
Then I receive a response code 200 Then I receive a response code 200
And I receive a response state running And I receive a response state running
And I receive a response role replica 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 Then I receive a response returncode 0
And I receive a response output "Success: reinitialize for member postgres1" And I receive a response output "Success: reinitialize for member postgres1"
And postgres1 role is the secondary after 30 seconds
And replication works from postgres0 to postgres1 after 20 seconds
When I run patronictl.py restart batman postgres0 --force When I run patronictl.py restart batman postgres0 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
And I receive a response output "Success: restart on member postgres0" And I receive a response output "Success: restart on member postgres0"
And postgres0 role is the primary after 5 seconds And postgres0 role is the primary after 5 seconds
When I sleep for 10 seconds
Then postgres1 role is the secondary after 15 seconds
Scenario: check the switchover via the API in the pause mode Scenario: check the switchover via the API in the pause mode
Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0", "candidate": "postgres1"} Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0", "candidate": "postgres1"}
+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
+26
View File
@@ -0,0 +1,26 @@
Feature: recovery
We want to check that crashed postgres is started back
Scenario: check that timeline is not incremented when primary is started after crash
Given I start postgres0
Then postgres0 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds
When I start postgres1
And I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
When I kill postmaster on postgres0
Then postgres0 role is the primary after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200
And I receive a response role master
And I receive a response timeline 1
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}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/config contains master_start_timeout after 10 seconds
When I kill postmaster on postgres0
Then postgres1 is a leader after 10 seconds
And postgres1 role is the primary after 10 seconds
+26 -14
View File
@@ -13,19 +13,20 @@ Feature: standby cluster
When I start postgres0 When I start postgres0
Then "members/postgres0" key in DCS has state=running after 10 seconds Then "members/postgres0" key in DCS has state=running after 10 seconds
And replication works from postgres1 to postgres0 after 15 seconds And replication works from postgres1 to postgres0 after 15 seconds
When I issue a GET request to http://127.0.0.1:8008/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And "members/postgres0" key in DCS has replication_state=streaming after 10 seconds
@slot-advance @slot-advance
Scenario: check permanent logical slots are synced to the replica Scenario: check permanent logical slots are synced to the replica
Given I run patronictl.py restart batman postgres1 --force Given I run patronictl.py restart batman postgres1 --force
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds 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 Scenario: Detach exiting node from the cluster
When I shut down postgres1 When I shut down postgres1
Then postgres0 is a leader after 10 seconds Then postgres0 is a leader after 10 seconds
And "members/postgres0" key in DCS has role=master after 3 seconds And "members/postgres0" key in DCS has role=master after 5 seconds
When I issue a GET request to http://127.0.0.1:8008/ When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200 Then I receive a response code 200
@@ -34,6 +35,9 @@ Feature: standby cluster
Then postgres1 is a leader of batman1 after 10 seconds Then postgres1 is a leader of batman1 after 10 seconds
When I add the table foo to postgres0 When I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds Then table foo is present on postgres1 after 20 seconds
When I issue a GET request to http://127.0.0.1:8009/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And I sleep for 3 seconds And I sleep for 3 seconds
When I issue a GET request to http://127.0.0.1:8009/primary When I issue a GET request to http://127.0.0.1:8009/primary
Then I receive a response code 503 Then I receive a response code 503
@@ -43,18 +47,26 @@ Feature: standby cluster
And there is a postgres1_cb.log with "on_role_change standby_leader batman1" in postgres1 data directory 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 When I start postgres2 in a cluster batman1
Then postgres2 role is the replica after 24 seconds Then postgres2 role is the replica after 24 seconds
And postgres2 is replicating from postgres1 after 10 seconds
And table foo is present on postgres2 after 20 seconds And table foo is present on postgres2 after 20 seconds
And postgres1 does not have a logical replication slot named test_logical 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 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 Scenario: check failover
When I kill postgres1 When I kill postgres2
And I kill postmaster on postgres1 And I kill postmaster on postgres2
Then postgres2 is replicating from postgres0 after 32 seconds Then postgres1 is replicating from postgres0 after 32 seconds
When I issue a GET request to http://127.0.0.1:8010/primary 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 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 I receive a response role standby_leader
And replication works from postgres0 to postgres2 after 15 seconds And replication works from postgres0 to postgres1 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 there is a postgres1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres1 data directory
+47 -7
View File
@@ -1,3 +1,4 @@
import json
import patroni.psycopg as pg import patroni.psycopg as pg
from behave import step, then from behave import step, then
@@ -9,6 +10,22 @@ def start_patroni(context, name):
return context.pctl.start(name) return context.pctl.start(name)
@step('I start duplicate {name:w} on port {port:d}')
def start_duplicate_patroni(context, name, port):
config = {
"name": name,
"restapi": {
"listen": "127.0.0.1:{0}".format(port)
}
}
try:
context.pctl.start('dup-' + name, custom_config=config)
assert False, "Process was expected to fail"
except AssertionError as e:
assert 'is not running after being started' in str(e), \
"No error was raised by duplicate start of {0} ".format(name)
@step('I shut down {name:w}') @step('I shut down {name:w}')
def stop_patroni(context, name): def stop_patroni(context, name):
return context.pctl.stop(name, timeout=60) return context.pctl.stop(name, timeout=60)
@@ -19,16 +36,27 @@ def kill_patroni(context, name):
return context.pctl.stop(name, kill=True) 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): def stop_postgres(context, name):
return context.pctl.stop(name, postgres=True) 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}') @step('I add the table {table_name:w} to {pg_name:w}')
def add_table(context, table_name, pg_name): def add_table(context, table_name, pg_name):
# parse the configuration file and get the port # parse the configuration file and get the port
try: try:
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name)) 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: except pg.Error as e:
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e) assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
@@ -37,9 +65,7 @@ def add_table(context, table_name, pg_name):
def toggle_wal_replay(context, action, pg_name): def toggle_wal_replay(context, action, pg_name):
# pause or resume the wal replay process # pause or resume the wal replay process
try: try:
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0] context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(get_wal_name(context, pg_name), action))
wal_name = 'xlog' if int(version)/10000 < 10 else 'wal'
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
except pg.Error as e: except pg.Error as e:
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e) assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
@@ -72,14 +98,14 @@ def table_is_present_on(context, table_name, pg_name, max_replication_delay):
break break
sleep(1) sleep(1)
else: else:
assert False,\ assert False, \
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay) "Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds') @then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
def check_role(context, pg_name, pg_role, max_promotion_timeout): def check_role(context, pg_name, pg_role, max_promotion_timeout):
max_promotion_timeout *= context.timeout_multiplier max_promotion_timeout *= context.timeout_multiplier
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)),\ assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)), \
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout) "{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
@@ -89,4 +115,18 @@ def replication_works(context, primary, replica, time_limit):
context.execute_steps(u""" context.execute_steps(u"""
When I add the table test_{0} to {1} When I add the table test_{0} to {1}
Then table test_{0} is present on {2} after {3} seconds Then table test_{0} is present on {2} after {3} seconds
""".format(int(time()), primary, replica, time_limit)) """.format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
@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: while time.time() < max_time:
try: try:
response = json.loads(context.dcs_ctl.query(name)) response = json.loads(context.dcs_ctl.query(name))
dcs_value = response.get(key) dcs_value = str(response.get(key))
if dcs_value == value: if dcs_value == value:
return return
except Exception: except Exception:
+33 -15
View File
@@ -35,7 +35,7 @@ def check_group_member(context, name, group, key, value, time_limit):
except Exception: except Exception:
pass pass
time.sleep(1) time.sleep(1)
assert False, ("{0} in a group {1} does not have {2}={3} (found {4}) in dcs" + assert False, ("{0} in a group {1} does not have {2}={3} (found {4}) in dcs"
" after {5} seconds").format(name, group, key, value, response, time_limit) " after {5} seconds").format(name, group, key, value, response, time_limit)
@@ -44,12 +44,24 @@ def start_citus(context, name, group):
return context.pctl.start(name, custom_config={"citus": {"database": "postgres", "group": int(group)}}) return context.pctl.start(name, custom_config={"citus": {"database": "postgres", "group": int(group)}})
@step('{name1:w} is registered in the {name2:w} as the worker in group {group:d}') @step('{name1:w} is registered in the {name2:w} as the {role:w} in group {group:d} after {time_limit:d} seconds')
def check_registration(context, name1, name2, group): def check_registration(context, name1, name2, role, group, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
worker_port = int(context.pctl.query(name1, "SHOW port").fetchone()[0]) worker_port = int(context.pctl.query(name1, "SHOW port").fetchone()[0])
r = context.pctl.query(name2, "SELECT nodeport FROM pg_catalog.pg_dist_node WHERE groupid = {0}".format(group))
assert worker_port == r.fetchone()[0],\ while time.time() < max_time:
"Worker {0} is not registered in pg_dist_node on the coordinator {1}".format(name1, name2) try:
cur = context.pctl.query(name2, "SELECT nodeport, noderole"
" FROM pg_catalog.pg_dist_node WHERE groupid = {0}".format(group))
mapping = {r[0]: r[1] for r in cur}
if mapping.get(worker_port) == role:
return
except Exception:
pass
time.sleep(1)
assert False, "Node {0} is not registered in pg_dist_node on the node {1}".format(name1, name2)
@step('I create a distributed table on {name:w}') @step('I create a distributed table on {name:w}')
@@ -93,7 +105,7 @@ def thread_is_alive(context):
@step("I stop a thread") @step("I stop a thread")
def stop_insert_thread(context): def stop_insert_thread(context):
context.thread_stop_event.set() context.thread_stop_event.set()
context.thread.join(1*context.timeout_multiplier) context.thread.join(1 * context.timeout_multiplier)
assert not context.thread.is_alive(), "Thread is still alive" assert not context.thread.is_alive(), "Thread is still alive"
@@ -103,15 +115,21 @@ def count_rows(context, name):
assert rows == context.insert_counter, "Distributed table doesn't have expected amount of rows" 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") @step("there is a transaction in progress on {name:w} changing pg_dist_node after {time_limit:d} seconds")
def check_transaction(context, name): def check_transaction(context, name, time_limit):
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()" time_limit *= context.timeout_multiplier
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'") max_time = time.time() + int(time_limit)
assert cur.rowcount == 1, "There is no idle in transaction updating pg_dist_node" while time.time() < max_time:
context.xact_start = cur.fetchone()[0] 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") @step("a transaction finishes in {timeout:d} seconds")
def check_transaction_timeout(context, timeout): def check_transaction_timeout(context, timeout):
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout,\ assert (datetime.now(tzutc) - context.xact_start).seconds >= timeout, \
"a transaction finished earlier than in {0} seconds".format(timeout) "a transaction finished earlier than in {0} seconds".format(timeout)
+8 -2
View File
@@ -98,7 +98,7 @@ def do_run(context, cmd):
@then('I receive a response {component:w} {data}') @then('I receive a response {component:w} {data}')
def check_response(context, component, data): def check_response(context, component, data):
if component == 'code': if component == 'code':
assert context.status_code == int(data),\ assert context.status_code == int(data), \
"status code {0} != {1}, response: {2}".format(context.status_code, data, context.response) "status code {0} != {1}, response: {2}".format(context.status_code, data, context.response)
elif component == 'returncode': elif component == 'returncode':
assert context.status_code == int(data), "return code {0} != {1}, {2}".format(context.status_code, assert context.status_code == int(data), "return code {0} != {1}, {2}".format(context.status_code,
@@ -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))) 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') @step('I add tag {tag:w} {value:w} to {pg_name:w} config')
def add_tag_to_config(context, tag, value, pg_name): def add_tag_to_config(context, tag, value, pg_name):
context.pctl.add_tag_to_config(pg_name, tag, value) context.pctl.add_tag_to_config(pg_name, tag, value)
@@ -158,7 +164,7 @@ def check_http_response(context, url, value, timeout, negate=False):
break break
time.sleep(1) time.sleep(1)
else: else:
assert False,\ assert False, \
"Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout) "Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout)
+62 -17
View File
@@ -1,3 +1,4 @@
import json
import time import time
from behave import step, then 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) 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') @step('{pg_name:w} has a logical replication slot named {slot_name}'
def has_logical_replication_slot(context, pg_name, slot_name, plugin): ' with the {plugin:w} plugin after {time_limit:d} seconds')
try: @then('{pg_name:w} has a logical replication slot named {slot_name}'
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots" ' with the {plugin:w} plugin after {time_limit:d} seconds')
" WHERE slot_name = '{0}'").format(slot_name)).fetchone() def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit):
assert row, "Couldn't find replication slot named {0}".format(slot_name) time_limit *= context.timeout_multiplier
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name) max_time = time.time() + int(time_limit)
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin " while time.time() < max_time:
"{1} rather than {2}").format(slot_name, row[1], plugin) try:
except pg.Error: row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin) 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}') @step('{pg_name:w} does not have a replication slot named {slot_name:w}')
def does_not_have_logical_replication_slot(context, pg_name, slot_name): @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: try:
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots" row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone() " WHERE slot_name = '{0}'").format(slot_name)).fetchone()
@@ -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) 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') @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 logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit): def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit):
time_limit *= context.timeout_multiplier time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit) 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: while time.time() < max_time:
try: 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() slot1 = context.pctl.query(pg_name1, query).fetchone()
slot2 = context.pctl.query(pg_name2, query).fetchone() slot2 = context.pctl.query(pg_name2, query).fetchone()
if slot1[0] == slot2[0]: 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: except Exception:
pass pass
time.sleep(1) 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}') @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): 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)) 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, "scope": cluster_name,
"postgresql": { "postgresql": {
"callbacks": callbacks(context, name), "callbacks": callbacks(context, name),
"backup_restore": { "backup_restore": context.pctl.backup_restore_config()
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir=" +
os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
} }
}) })
@@ -34,6 +32,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
"ttl": 20, "ttl": 20,
"loop_wait": 2, "loop_wait": 2,
"retry_timeout": 5, "retry_timeout": 5,
"synchronous_mode": True, # should be completely ignored
"standby_cluster": { "standby_cluster": {
"host": "localhost", "host": "localhost",
"port": port, "port": port,
+3 -3
View File
@@ -1,4 +1,4 @@
FROM postgres:15 FROM postgres:16
LABEL maintainer="Alexander Kukushkin <[email protected]>" LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \ 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 \ | 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 ## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \ && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& pip3 install setuptools \ && pip3 install --break-system-packages setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ && pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \ && PGHOME=/home/postgres \
&& mkdir -p $PGHOME \ && mkdir -p $PGHOME \
&& chown postgres $PGHOME \ && chown postgres $PGHOME \
+26 -7
View File
@@ -1,4 +1,4 @@
FROM postgres:15 FROM postgres:16
LABEL maintainer="Alexander Kukushkin <[email protected]>" LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \ RUN export DEBIAN_FRONTEND=noninteractive \
@@ -7,13 +7,27 @@ RUN export DEBIAN_FRONTEND=noninteractive \
&& apt-get upgrade -y \ && apt-get upgrade -y \
&& apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \ && apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \ | grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel \ | xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \
## Make sure we have a en_US.UTF-8 locale available ## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \ && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& curl https://install.citusdata.com/community/deb.sh | bash \ && if [ $(dpkg --print-architecture) = 'arm64' ]; then \
&& apt-get -y install postgresql-15-citus-11.2 \ apt-get install -y postgresql-server-dev-16 \
&& pip3 install setuptools \ gcc make autoconf \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ 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 \ && PGHOME=/home/postgres \
&& mkdir -p $PGHOME \ && mkdir -p $PGHOME \
&& chown postgres $PGHOME \ && chown postgres $PGHOME \
@@ -24,6 +38,9 @@ RUN export DEBIAN_FRONTEND=noninteractive \
&& chmod 664 /etc/passwd \ && chmod 664 /etc/passwd \
# Clean up # Clean up
&& apt-get remove -y git python3-pip python3-wheel \ && 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 autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /root/.cache && rm -rf /var/lib/apt/lists/* /root/.cache
@@ -32,7 +49,9 @@ ADD entrypoint.sh /
ENV PGSSLMODE=verify-ca PGSSLKEY=/etc/ssl/private/ssl-cert-snakeoil.key PGSSLCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem ENV PGSSLMODE=verify-ca PGSSLKEY=/etc/ssl/private/ssl-cert-snakeoil.key PGSSLCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
RUN sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' /entrypoint.sh \ RUN sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' /entrypoint.sh \
&& sed -i "s|^ postgresql:|&\n pg_hba:\n - local all all trust\n - hostssl replication all all md5 clientcert=$PGSSLMODE\n - hostssl all all all md5 clientcert=$PGSSLMODE\n parameters:\n max_connections: 100\n shared_buffers: 16MB\n ssl: 'on'\n ssl_ca_file: $PGSSLROOTCERT\n ssl_cert_file: $PGSSLCERT\n ssl_key_file: $PGSSLKEY\n citus.node_conninfo: 'sslrootcert=$PGSSLROOTCERT sslkey=$PGSSLKEY sslcert=$PGSSLCERT sslmode=$PGSSLMODE'|" /entrypoint.sh \ && sed -i "s|^ postgresql:|&\n parameters:\n max_connections: 100\n shared_buffers: 16MB\n ssl: 'on'\n ssl_ca_file: $PGSSLROOTCERT\n ssl_cert_file: $PGSSLCERT\n ssl_key_file: $PGSSLKEY\n citus.node_conninfo: 'sslrootcert=$PGSSLROOTCERT sslkey=$PGSSLKEY sslcert=$PGSSLCERT sslmode=$PGSSLMODE'|" /entrypoint.sh \
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' /entrypoint.sh \
&& sed -i "s/^\(.*\) \(.*\) \(.*\) \(.*\) \(.*\) md5.*$/\1 hostssl \3 \4 all md5 clientcert=$PGSSLMODE/" /entrypoint.sh \
&& sed -i "s#^ \(superuser\|replication\):#&\n sslmode: $PGSSLMODE\n sslkey: $PGSSLKEY\n sslcert: $PGSSLCERT\n sslrootcert: $PGSSLROOTCERT#" /entrypoint.sh && sed -i "s#^ \(superuser\|replication\):#&\n sslmode: $PGSSLMODE\n sslkey: $PGSSLKEY\n sslcert: $PGSSLCERT\n sslrootcert: $PGSSLROOTCERT#" /entrypoint.sh
EXPOSE 5432 8008 EXPOSE 5432 8008
+3 -3
View File
@@ -12,15 +12,15 @@ bootstrap:
dcs: dcs:
postgresql: postgresql:
use_pg_rewind: true use_pg_rewind: true
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
initdb: initdb:
- auth-host: md5 - auth-host: md5
- auth-local: trust - auth-local: trust
- encoding: UTF8 - encoding: UTF8
- locale: en_US.UTF-8 - locale: en_US.UTF-8
- data-checksums - data-checksums
pg_hba:
- host all all 0.0.0.0/0 md5
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
restapi: restapi:
connect_address: '${PATRONI_KUBERNETES_POD_IP}:8008' connect_address: '${PATRONI_KUBERNETES_POD_IP}:8008'
postgresql: postgresql:
+8 -1
View File
@@ -13,10 +13,17 @@ def hiddenimports():
sys.path.pop(0) sys.path.pop(0)
def resources():
import os
res_dir = 'patroni/postgresql/available_parameters/'
exts = set(f.split('.')[-1] for f in os.listdir(res_dir))
return [(res_dir + '*.' + e, res_dir) for e in exts if e.lower() in {'yml', 'yaml'}]
a = Analysis(['patroni/__main__.py'], a = Analysis(['patroni/__main__.py'],
pathex=[], pathex=[],
binaries=None, binaries=None,
datas=None, datas=resources(),
hiddenimports=hiddenimports(), hiddenimports=hiddenimports(),
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
+34 -27
View File
@@ -1,41 +1,48 @@
import sys """Define general variables and functions for :mod:`patroni`.
:var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables.
:var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables.
:var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work.
:var MIN_PSYCOPG3: minimum version of :mod:`psycopg` required by Patroni to work.
"""
from typing import Iterator, Tuple
PATRONI_ENV_PREFIX = 'PATRONI_' PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_' KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
MIN_PSYCOPG2 = (2, 5, 4) MIN_PSYCOPG2 = (2, 5, 4)
MIN_PSYCOPG3 = (3, 0, 0)
def fatal(string, *args): def parse_version(version: str) -> Tuple[int, ...]:
sys.stderr.write('FATAL: ' + string.format(*args) + '\n') """Convert *version* from human-readable format to tuple of integers.
sys.exit(1)
.. note::
Designed for easy comparison of software versions in Python.
def parse_version(version): :param version: human-readable software version, e.g. ``2.5.4.dev1 (dt dec pq3 ext lo64)``.
def _parse_version(version):
:returns: tuple of *version* parts, each part as an integer.
:Example:
>>> 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.dev1``.
:yields: each part of *version* as an integer.
:Example:
>>> tuple(_parse_version('2.5.4.dev1'))
(2, 5, 4)
"""
for e in version.split('.'): for e in version.split('.'):
try: try:
yield int(e) yield int(e)
except ValueError: except ValueError:
break break
return tuple(_parse_version(version.split(' ')[0])) return tuple(_parse_version(version.split(' ')[0]))
# We pass MIN_PSYCOPG2 and parse_version as arguments to simplify usage of check_psycopg from the setup.py
def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version):
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
try:
from psycopg2 import __version__
if _parse_version(__version__) >= _min_psycopg2:
return
version_str = __version__.split(' ')[0]
except ImportError:
version_str = None
try:
from psycopg import __version__
except ImportError:
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
if version_str:
error += ', but only psycopg2=={0} is available'.format(version_str)
fatal(error)
+251 -39
View File
@@ -1,16 +1,54 @@
"""Patroni main entry point.
Implement ``patroni`` main daemon and expose its entry point.
"""
import logging import logging
import os import os
import signal import signal
import sys
import time import time
from patroni.daemon import AbstractPatroniDaemon, abstract_main from argparse import Namespace
from typing import Any, Dict, 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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Patroni(AbstractPatroniDaemon): class Patroni(AbstractPatroniDaemon, Tags):
"""Implement ``patroni`` command daemon.
def __init__(self, config): :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.api import RestApiServer
from patroni.dcs import get_dcs from patroni.dcs import get_dcs
from patroni.ha import Ha from patroni.ha import Ha
@@ -23,19 +61,33 @@ class Patroni(AbstractPatroniDaemon):
self.version = __version__ self.version = __version__
self.dcs = get_dcs(self.config) self.dcs = get_dcs(self.config)
self.request = PatroniRequest(self.config, True)
self.ensure_unique_name()
self.watchdog = Watchdog(self.config) self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration() self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql']) self.postgresql = Postgresql(self.config['postgresql'], self.dcs.mpp)
self.api = RestApiServer(self, self.config['restapi']) self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self) self.ha = Ha(self)
self.tags = self.get_tags() self._tags = self._get_tags()
self.next_run = time.time() self.next_run = time.time()
self.scheduled_restart = {} self.scheduled_restart: Dict[str, Any] = {}
def load_dynamic_configuration(self): def load_dynamic_configuration(self) -> None:
"""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 from patroni.exceptions import DCSError
while True: while True:
try: try:
@@ -53,23 +105,51 @@ class Patroni(AbstractPatroniDaemon):
logger.warning('Can not get cluster from dcs') logger.warning('Can not get cluster from dcs')
time.sleep(5) time.sleep(5)
def get_tags(self): def ensure_unique_name(self) -> None:
return {tag: value for tag, value in self.config.get('tags', {}).items() """A helper method to prevent splitbrain from operator naming error."""
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value} from patroni.dcs import Member
@property cluster = self.dcs.get_cluster()
def nofailover(self): if not cluster:
return bool(self.tags.get('nofailover', False)) return
member = cluster.get_member(self.config['name'], False)
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:
self.logger.update_loggers({})
@property def _get_tags(self) -> Dict[str, Any]:
def nosync(self): """Get tags configured for this node, if any.
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=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: try:
super(Patroni, self).reload_config(sighup, local) super(Patroni, self).reload_config(sighup, local)
if local: if local:
self.tags = self.get_tags() self._tags = self._get_tags()
self.request.reload_config(self.config) self.request.reload_config(self.config)
if local or sighup and self.api.reload_local_certificate(): if local or sighup and self.api.reload_local_certificate():
self.api.reload_config(self.config['restapi']) self.api.reload_config(self.config['restapi'])
@@ -80,14 +160,16 @@ class Patroni(AbstractPatroniDaemon):
logger.exception('Failed to reload config_file=%s', self.config.config_file) logger.exception('Failed to reload config_file=%s', self.config.config_file)
@property @property
def replicatefrom(self): def tags(self) -> Dict[str, Any]:
return self.tags.get('replicatefrom') """Tags configured for this node, if any."""
return self._tags
@property def schedule_next_run(self) -> None:
def noloadbalance(self): """Schedule the next run of the ``patroni`` daemon main loop.
return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self): 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 self.next_run += self.dcs.loop_wait
current_time = time.time() current_time = time.time()
nap_time = self.next_run - current_time nap_time = self.next_run - current_time
@@ -100,12 +182,22 @@ class Patroni(AbstractPatroniDaemon):
elif self.ha.watch(nap_time): elif self.ha.watch(nap_time):
self.next_run = time.time() self.next_run = time.time()
def run(self): def run(self) -> None:
"""Run ``patroni`` daemon process main loop.
Start the REST API and keep running HA cycles every ``loop_wait`` seconds.
"""
self.api.start() self.api.start()
self.next_run = time.time() self.next_run = time.time()
super(Patroni, self).run() super(Patroni, self).run()
def _run_cycle(self): def _run_cycle(self) -> None:
"""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()) logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \ if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
@@ -117,7 +209,11 @@ class Patroni(AbstractPatroniDaemon):
self.schedule_next_run() self.schedule_next_run()
def _shutdown(self): def _shutdown(self) -> None:
"""Perform shutdown of ``patroni`` daemon process.
Shut down the REST API and the HA handler.
"""
try: try:
self.api.shutdown() self.api.shutdown()
except Exception: except Exception:
@@ -128,27 +224,138 @@ class Patroni(AbstractPatroniDaemon):
logger.exception('Exception during Ha.shutdown') logger.exception('Exception during Ha.shutdown')
def patroni_main(): def patroni_main(configfile: str) -> None:
"""Configure and start ``patroni`` main daemon process.
: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()
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.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
try:
Config(args.configfile, validator=schema)
sys.exit()
except ConfigParseError as e:
sys.exit(e.value)
return args
def check_psycopg() -> None:
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
.. 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 from multiprocessing import freeze_support
from patroni.validator import schema
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support() freeze_support()
abstract_main(Patroni, schema)
check_psycopg()
args = process_arguments()
def main():
if os.getpid() != 1: if os.getpid() != 1:
from patroni import check_psycopg return patroni_main(args.configfile)
check_psycopg()
return patroni_main()
# Patroni started with PID=1, it looks like we are in the container # Patroni started with PID=1, it looks like we are in the container
from types import FrameType
pid = 0 pid = 0
# Looks like we are in a docker, so we will act like init # Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame): def sigchld_handler(signo: int, stack_frame: Optional[FrameType]) -> None:
"""Handle ``SIGCHLD`` received by main process from ``patroni`` daemon when the daemon terminates.
:param signo: signal number.
:param stack_frame: current stack frame.
"""
try: try:
# log exit code of all children processes, and break loop when there is none left
while True: while True:
ret = os.waitpid(-1, os.WNOHANG) ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0): if ret == (0, 0):
@@ -158,7 +365,12 @@ def main():
except OSError: except OSError:
pass pass
def passtochild(signo, stack_frame): 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: if pid:
os.kill(pid, signo) os.kill(pid, signo)
@@ -173,7 +385,7 @@ def main():
signal.signal(signal.SIGTERM, passtochild) signal.signal(signal.SIGTERM, passtochild)
import multiprocessing import multiprocessing
patroni = multiprocessing.Process(target=patroni_main) patroni = multiprocessing.Process(target=patroni_main, args=(args.configfile,))
patroni.start() patroni.start()
pid = patroni.pid pid = patroni.pid
patroni.join() patroni.join()
+1014 -191
View File
File diff suppressed because it is too large Load Diff
+125 -27
View File
@@ -1,5 +1,11 @@
"""Implement facilities for executing asynchronous tasks."""
import logging import logging
from threading import Event, Lock, RLock, Thread from threading import Event, Lock, RLock, Thread
from types import TracebackType
from typing import Any, Callable, Optional, Tuple, Type
from .postgresql.cancellable import CancellableSubprocess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -8,65 +14,110 @@ class CriticalTask(object):
"""Represents a critical task in a background process that we either need to cancel or get the result of. """Represents a critical task in a background process that we either need to cancel or get the result of.
Fields of this object may be accessed only when holding a lock on it. To perform the critical task the background Fields of this object may be accessed only when holding a lock on it. To perform the critical task the background
thread must, while holding lock on this object, check `is_cancelled` flag, run the task and mark the task as thread must, while holding lock on this object, check ``is_cancelled`` flag, run the task and mark the task as
complete using `complete()`. complete using :func:`complete`.
The main thread must hold async lock to prevent the task from completing, hold lock on critical task object, The main thread must hold async lock to prevent the task from completing, hold lock on critical task object,
call cancel. If the task has completed `cancel()` will return False and `result` field will contain the result of call :func:`cancel`. If the task has completed :func:`cancel` will return ``False`` and ``result`` field will
the task. When cancel returns True it is guaranteed that the background task will notice the `is_cancelled` flag. contain the result of the task. When :func:`cancel` returns ``True`` it is guaranteed that the background task will
notice the ``is_cancelled`` flag.
:ivar is_cancelled: if the critical task has been cancelled.
:ivar result: contains the result of the task, if it has already been completed.
""" """
def __init__(self):
def __init__(self) -> None:
"""Create a new instance of :class:`CriticalTask`.
Instantiate the lock and the task control attributes.
"""
self._lock = Lock() self._lock = Lock()
self.is_cancelled = False self.is_cancelled = False
self.result = None self.result = None
def reset(self): def reset(self) -> None:
"""Must be called every time the background task is finished. """Must be called every time the background task is finished.
Must be called from async thread. Caller must hold lock on async executor when calling.""" .. note::
Must be called from async thread. Caller must hold lock on async executor when calling.
"""
self.is_cancelled = False self.is_cancelled = False
self.result = None self.result = None
def cancel(self): def cancel(self) -> bool:
"""Tries to cancel the task, returns True if the task has already run. """Tries to cancel the task.
Caller must hold lock on async executor and the task when calling.""" .. note::
Caller must hold lock on async executor and the task when calling.
:returns: ``False`` if the task has already run, or ``True`` it has been cancelled.
"""
if self.result is not None: if self.result is not None:
return False return False
self.is_cancelled = True self.is_cancelled = True
return True return True
def complete(self, result): def complete(self, result: Any) -> None:
"""Mark task as completed along with a result. """Mark task as completed along with a *result*.
Must be called from async thread. Caller must hold lock on task when calling.""" .. note::
Must be called from async thread. Caller must hold lock on task when calling.
"""
self.result = result self.result = result
def __enter__(self): def __enter__(self) -> 'CriticalTask':
"""Acquire the object lock when entering the context manager."""
self._lock.acquire() self._lock.acquire()
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
"""Release the object lock when exiting the context manager."""
self._lock.release() self._lock.release()
class AsyncExecutor(object): class AsyncExecutor(object):
"""Asynchronous executor of (long) tasks.
def __init__(self, cancellable, ha_wakeup): :ivar critical_task: a :class:`CriticalTask` instance to handle execution of critical background tasks.
"""
def __init__(self, cancellable: CancellableSubprocess, ha_wakeup: Callable[..., None]) -> None:
"""Create a new instance of :class:`AsyncExecutor`.
Configure the given *cancellable* and *ha_wakeup*, initializes the control attributes, and instantiate the lock
and event objects that are used to access attributes and manage communication between threads.
:param cancellable: a subprocess that supports being cancelled.
:param ha_wakeup: function to wake up the HA loop.
"""
self._cancellable = cancellable self._cancellable = cancellable
self._ha_wakeup = ha_wakeup self._ha_wakeup = ha_wakeup
self._thread_lock = RLock() self._thread_lock = RLock()
self._scheduled_action = None self._scheduled_action: Optional[str] = None
self._scheduled_action_lock = RLock() self._scheduled_action_lock = RLock()
self._is_cancelled = False self._is_cancelled = False
self._finish_event = Event() self._finish_event = Event()
self.critical_task = CriticalTask() self.critical_task = CriticalTask()
@property @property
def busy(self): def busy(self) -> bool:
"""``True`` if there is an action scheduled to occur, else ``False``."""
return self.scheduled_action is not None return self.scheduled_action is not None
def schedule(self, action): def schedule(self, action: str) -> Optional[str]:
"""Schedule *action* to be executed.
.. note::
Must be called before executing a task.
.. note::
*action* can only be scheduled if there is no other action currently scheduled.
:param action: action to be executed.
:returns: ``None`` if *action* has been successfully scheduled, or the previously scheduled action, if any.
"""
with self._scheduled_action_lock: with self._scheduled_action_lock:
if self._scheduled_action is not None: if self._scheduled_action is not None:
return self._scheduled_action return self._scheduled_action
@@ -76,15 +127,33 @@ class AsyncExecutor(object):
return None return None
@property @property
def scheduled_action(self): def scheduled_action(self) -> Optional[str]:
"""The currently scheduled action, if any, else ``None``."""
with self._scheduled_action_lock: with self._scheduled_action_lock:
return self._scheduled_action return self._scheduled_action
def reset_scheduled_action(self): def reset_scheduled_action(self) -> None:
"""Unschedule a previously scheduled action, if any.
.. note::
Must be called once the scheduled task finishes or is cancelled.
"""
with self._scheduled_action_lock: with self._scheduled_action_lock:
self._scheduled_action = None self._scheduled_action = None
def run(self, func, args=()): def run(self, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> Optional[Any]:
"""Run *func* with *args*.
.. note::
Expected to be executed through a thread.
:param func: function to be run. If it returns anything other than ``None``, HA loop will be woken up at the end
of :func:`run` execution.
:param args: arguments to be passed to *func*.
:returns: ``None`` if *func* execution has been cancelled or faced any exception, otherwise the result of
*func*.
"""
wakeup = False wakeup = False
try: try:
with self: with self:
@@ -107,16 +176,37 @@ class AsyncExecutor(object):
if wakeup is not None: if wakeup is not None:
self._ha_wakeup() self._ha_wakeup()
def run_async(self, func, args=()): def run_async(self, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> None:
"""Start an async thread that runs *func* with *args*.
:param func: function to be run. Will be passed through args to :class:`~threading.Thread` with a target of
:func:`run`.
:param args: arguments to be passed along to :class:`~threading.Thread` with *func*.
"""
Thread(target=self.run, args=(func, args)).start() Thread(target=self.run, args=(func, args)).start()
def try_run_async(self, action, func, args=()): def try_run_async(self, action: str, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> Optional[str]:
"""Try to run an async task, if none is currently being executed.
:param action: name of the task to be executed.
:param func: actual function that performs the task *action*.
:param args: arguments to be passed to *func*.
:returns: ``None`` if *func* was scheduled successfully, otherwise an error message informing of an already
ongoing task.
"""
prev = self.schedule(action) prev = self.schedule(action)
if prev is None: if prev is None:
return self.run_async(func, args) return self.run_async(func, args)
return 'Failed to run {0}, {1} is already in progress'.format(action, prev) return 'Failed to run {0}, {1} is already in progress'.format(action, prev)
def cancel(self): def cancel(self) -> None:
"""Request cancellation of a scheduled async task, if any.
.. note::
Wait until task is cancelled before returning control to caller.
"""
with self: with self:
with self._scheduled_action_lock: with self._scheduled_action_lock:
if self._scheduled_action is None: if self._scheduled_action is None:
@@ -130,8 +220,16 @@ class AsyncExecutor(object):
with self: with self:
self.reset_scheduled_action() self.reset_scheduled_action()
def __enter__(self): def __enter__(self) -> 'AsyncExecutor':
"""Acquire the thread lock when entering the context manager."""
self._thread_lock.acquire() self._thread_lock.acquire()
return self
def __exit__(self, *args): def __exit__(self, exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
"""Release the thread lock when exiting the context manager.
.. note::
The arguments are not used, but we need them to match the expected method signature.
"""
self._thread_lock.release() self._thread_lock.release()
+254
View File
@@ -0,0 +1,254 @@
"""Patroni custom object types somewhat like :mod:`collections` module.
Provides a case insensitive :class:`dict` and :class:`set` object types, and `EMPTY_DICT` frozen dictionary object.
"""
from collections import OrderedDict
from copy import deepcopy
from typing import Any, Collection, Dict, Iterator, KeysView, Mapping, MutableMapping, MutableSet, Optional
class CaseInsensitiveSet(MutableSet[str]):
"""A case-insensitive :class:`set`-like object.
Implements all methods and operations of :class:`~typing.MutableSet`. All values are expected to be strings.
The structure remembers the case of the last value set, however, contains testing is case insensitive.
"""
def __init__(self, values: Optional[Collection[str]] = None) -> None:
"""Create a new instance of :class:`CaseInsensitiveSet` with the given *values*.
:param values: values to be added to the set.
"""
self._values: Dict[str, str] = {}
for v in values or ():
self.add(v)
def __repr__(self) -> str:
"""Get a string representation of the set.
Provide a helpful way of recreating the set.
:returns: representation of the set, showing its values.
:Example:
>>> repr(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2'))) # doctest: +ELLIPSIS
"<CaseInsensitiveSet('1', 'TESt', 'test2') at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, tuple(self._values.values()), id(self))
def __str__(self) -> str:
"""Get set values for printing.
:returns: set of values in string format.
:Example:
>>> str(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2'))) # doctest: +SKIP
"{'TESt', 'test2', '1'}"
"""
return str(set(self._values.values()))
def __contains__(self, value: object) -> bool:
"""Check if set contains *value*.
The check is performed case-insensitively.
:param value: value to be checked.
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
"""
return isinstance(value, str) and value.lower() in self._values
def __iter__(self) -> Iterator[str]:
"""Iterate over the values in this set.
:yields: values from set.
"""
return iter(self._values.values())
def __len__(self) -> int:
"""Get the length of this set.
:returns: number of values in the set.
:Example:
>>> len(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2')))
3
"""
return len(self._values)
def add(self, value: str) -> None:
"""Add *value* to this set.
Search is performed case-insensitively. If *value* is already in the set, overwrite it with *value*, so we
"remember" the last case of *value*.
:param value: value to be added to the set.
"""
self._values[value.lower()] = value
def discard(self, value: str) -> None:
"""Remove *value* from this set.
Search is performed case-insensitively. If *value* is not present in the set, no exception is raised.
:param value: value to be removed from the set.
"""
self._values.pop(value.lower(), None)
def issubset(self, other: 'CaseInsensitiveSet') -> bool:
"""Check if this set is a subset of *other*.
:param other: another set to be compared with this set.
:returns: ``True`` if this set is a subset of *other*, else ``False``.
"""
return self <= other
class CaseInsensitiveDict(MutableMapping[str, Any]):
"""A case-insensitive :class:`dict`-like object.
Implements all methods and operations of :class:`~typing.MutableMapping` as well as :class:`dict`'s
:func:`~dict.copy`. All keys are expected to be strings. The structure remembers the case of the last key to be set,
and :func:`iter`, :func:`dict.keys`, :func:`dict.items`, :func:`dict.iterkeys`, and :func:`dict.iteritems` will
contain case-sensitive keys. However, querying and contains testing is case insensitive.
"""
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
"""Create a new instance of :class:`CaseInsensitiveDict` with the given *data*.
:param data: initial dictionary to create a :class:`CaseInsensitiveDict` from.
"""
self._values: OrderedDict[str, Any] = OrderedDict()
self.update(data or {})
def __setitem__(self, key: str, value: Any) -> None:
"""Assign *value* to *key* in this dict.
*key* is searched/stored case-insensitively in the dict. The corresponding value in the dict is a tuple of:
* original *key*;
* *value*.
:param key: key to be created or updated in the dict.
:param value: value for *key*.
"""
self._values[key.lower()] = (key, value)
def __getitem__(self, key: str) -> Any:
"""Get the value corresponding to *key*.
*key* is searched case-insensitively in the dict.
.. note:
If *key* is not present in the dict, :class:`KeyError` will be triggered.
:param key: key to be searched in the dict.
:returns: value corresponding to *key*.
"""
return self._values[key.lower()][1]
def __delitem__(self, key: str) -> None:
"""Remove *key* from this dict.
*key* is searched case-insensitively in the dict.
.. note:
If *key* is not present in the dict, :class:`KeyError` will be triggered.
:param key: key to be removed from the dict.
"""
del self._values[key.lower()]
def __iter__(self) -> Iterator[str]:
"""Iterate over keys of this dict.
:yields: each key present in the dict. Yields each key with its last case that has been stored.
"""
return iter(key for key, _ in self._values.values())
def __len__(self) -> int:
"""Get the length of this dict.
:returns: number of keys in the dict.
:Example:
>>> len(CaseInsensitiveDict({'a': 'b', 'A': 'B', 'c': 'd'}))
2
"""
return len(self._values)
def copy(self) -> 'CaseInsensitiveDict':
"""Create a copy of this dict.
:return: a new dict object with the same keys and values of this dict.
"""
return CaseInsensitiveDict({v[0]: v[1] for v in self._values.values()})
def 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.
Provide a helpful way of recreating the dict.
:returns: representation of the dict, showing its keys and values.
:Example:
>>> repr(CaseInsensitiveDict({'a': 'b', 'A': 'B', 'c': 'd'})) # doctest: +ELLIPSIS
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
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()
+470 -114
View File
@@ -1,3 +1,5 @@
"""Facilities related to Patroni configuration."""
import re
import json import json
import logging import logging
import os import os
@@ -7,11 +9,16 @@ import yaml
from collections import defaultdict from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from patroni import PATRONI_ENV_PREFIX from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
from patroni.exceptions import ConfigParseError
from patroni.dcs import ClusterConfig from . import PATRONI_ENV_PREFIX
from patroni.postgresql.config import CaseInsensitiveDict, ConfigHandler from .collections import CaseInsensitiveDict, EMPTY_DICT
from patroni.utils import deep_compare, parse_bool, parse_int, patch_config from .dcs import ClusterConfig
from .exceptions import ConfigParseError
from .file_perm import pg_perm
from .postgresql.config import ConfigHandler
from .validator import IntValidator
from .utils import deep_compare, parse_bool, parse_int, patch_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,49 +37,55 @@ _AUTH_ALLOWED_PARAMETERS = (
) )
def default_validator(conf): 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: if not conf:
raise ConfigParseError("Config is empty.") raise ConfigParseError("Config is empty.")
return []
class Config(object): class Config(object):
""" """Handle Patroni configuration.
This class is responsible for: This class is responsible for:
1) Building and giving access to `effective_configuration` from: 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
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. located in local_configuration['postgresql']['data_dir'] directory.
This is necessary to be able to restore `dynamic_configuration` This is necessary to be able to restore ``dynamic_configuration``
if DCS was accidentally wiped 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 4) Mimicking some ``dict`` interfaces to make it possible
to work with it as with the old `config` object. 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' PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION'
__CACHE_FILENAME = 'patroni.dynamic.json' __CACHE_FILENAME = 'patroni.dynamic.json'
__REMAP_KEYS = { __DEFAULT_CONFIG: Dict[str, Any] = {
'master_start_timeout': 'primary_start_timeout',
'master_stop_timeout': 'primary_stop_timeout'
}
__DEFAULT_CONFIG = {
'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10, 'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10,
'maximum_lag_on_failover': 1048576,
'maximum_lag_on_syncnode': -1,
'check_timeline': False,
'primary_start_timeout': 300,
'primary_stop_timeout': 0,
'synchronous_mode': False,
'synchronous_mode_strict': False,
'synchronous_node_count': 1,
'failsafe_mode': False,
'standby_cluster': { 'standby_cluster': {
'create_replica_methods': '', 'create_replica_methods': '',
'host': '', 'host': '',
@@ -83,29 +96,45 @@ class Config(object):
'recovery_min_apply_delay': '' 'recovery_min_apply_delay': ''
}, },
'postgresql': { 'postgresql': {
'bin_dir': '',
'use_slots': True, 'use_slots': True,
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items() 'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()
if p not in ('wal_keep_segments', 'wal_keep_size')}) if v[0] is not None and p not in ('wal_keep_segments', 'wal_keep_size')})
},
'watchdog': {
'mode': 'automatic',
} }
} }
def __init__(self, configfile, validator=default_validator): def __init__(self, configfile: str,
self._modify_index = -1 validator: Optional[Callable[[Dict[str, Any]], List[str]]] = default_validator) -> None:
"""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._dynamic_configuration = {}
self.__environment_configuration = self._build_environment_configuration() self.__environment_configuration = self._build_environment_configuration()
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment self._config_file = configfile if configfile and os.path.exists(configfile) else None
self._config_file = configfile and os.path.exists(configfile) and configfile
if self._config_file: if self._config_file:
self._local_configuration = self._load_config_file() self._local_configuration = self._load_config_file()
else: else:
config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None) config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None)
self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration
if validator: if validator:
errors = validator(self._local_configuration) errors = validator(self._local_configuration)
if errors: if errors:
@@ -114,24 +143,49 @@ class Config(object):
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration) self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "") self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME) self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
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 self._cache_needs_saving = False
@property @property
def config_file(self): def config_file(self) -> Optional[str]:
"""Path to Patroni configuration file, if any, else ``None``."""
return self._config_file return self._config_file
@property @property
def dynamic_configuration(self): def dynamic_configuration(self) -> Dict[str, Any]:
"""Deep copy of cached Patroni dynamic configuration."""
return deepcopy(self._dynamic_configuration) return deepcopy(self._dynamic_configuration)
def check_mode(self, mode): @property
return bool(parse_bool(self._dynamic_configuration.get(mode))) def local_configuration(self) -> Dict[str, Any]:
"""Deep copy of cached Patroni local configuration.
def _load_config_path(self, path): :returns: copy of :attr:`~Config._local_configuration`
""" """
If path is a file, loads the yml file pointed to by path. return deepcopy(dict(self._local_configuration))
If path is a directory, loads all yml files in that directory in alphabetical order
@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): if os.path.isfile(path):
files = [path] files = [path]
@@ -142,20 +196,26 @@ class Config(object):
logger.error('config path %s is neither directory nor file', path) logger.error('config path %s is neither directory nor file', path)
raise ConfigParseError('invalid config path') raise ConfigParseError('invalid config path')
overall_config = {} overall_config: Dict[str, Any] = {}
for fname in files: for fname in files:
with open(fname) as f: with open(fname) as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
patch_config(overall_config, config) patch_config(overall_config, config)
return overall_config return overall_config
def _load_config_file(self): def _load_config_file(self) -> Dict[str, Any]:
"""Loads config.yaml from filesystem and applies some values which were set via ENV""" """Load configuration file(s) from filesystem and apply values which were set via environment variables.
config = self._load_config_path(self._config_file)
: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)
patch_config(config, self.__environment_configuration) patch_config(config, self.__environment_configuration)
return config return config
def _load_cache(self): def _load_cache(self) -> None:
"""Load dynamic configuration from ``patroni.dynamic.json``."""
if os.path.isfile(self._cache_file): if os.path.isfile(self._cache_file):
try: try:
with open(self._cache_file) as f: with open(self._cache_file) as f:
@@ -163,15 +223,23 @@ class Config(object):
except Exception: except Exception:
logger.exception('Exception when loading file: %s', self._cache_file) logger.exception('Exception when loading file: %s', self._cache_file)
def save_cache(self): def save_cache(self) -> None:
"""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: if self._cache_needs_saving:
tmpfile = fd = None tmpfile = fd = None
try: try:
pg_perm.set_permissions_from_data_directory(self._data_dir)
(fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir) (fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir)
with os.fdopen(fd, 'w') as f: with os.fdopen(fd, 'w') as f:
fd = None fd = None
json.dump(self.dynamic_configuration, f) json.dump(self.dynamic_configuration, f)
tmpfile = shutil.move(tmpfile, self._cache_file) tmpfile = shutil.move(tmpfile, self._cache_file)
os.chmod(self._cache_file, pg_perm.file_create_mode)
self._cache_needs_saving = False self._cache_needs_saving = False
except Exception: except Exception:
logger.exception('Exception when saving file: %s', self._cache_file) logger.exception('Exception when saving file: %s', self._cache_file)
@@ -186,16 +254,84 @@ class Config(object):
except Exception: except Exception:
logger.error('Can not remove temporary file %s', tmpfile) 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 # configuration could be either ClusterConfig or dict
def set_dynamic_configuration(self, configuration): def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool:
"""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 isinstance(configuration, ClusterConfig):
if self._modify_index == configuration.modify_index: if self._modify_version == configuration.modify_version:
return False # If the index didn't changed there is nothing to do return False # If the version didn't change there is nothing to do
self._modify_index = configuration.modify_index self._modify_version = configuration.modify_version
configuration = configuration.data configuration = configuration.data
if not deep_compare(self._dynamic_configuration, configuration): if not deep_compare(self._dynamic_configuration, configuration):
try: try:
self._validate_and_adjust_timeouts(configuration)
self.__effective_configuration = self._build_effective_configuration(configuration, self.__effective_configuration = self._build_effective_configuration(configuration,
self._local_configuration) self._local_configuration)
self._dynamic_configuration = configuration self._dynamic_configuration = configuration
@@ -203,8 +339,17 @@ class Config(object):
return True return True
except Exception: except Exception:
logger.exception('Exception when setting dynamic_configuration') logger.exception('Exception when setting dynamic_configuration')
return False
def reload_local_configuration(self): def reload_local_configuration(self) -> Optional[bool]:
"""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: if self.config_file:
try: try:
configuration = self._load_config_file() configuration = self._load_config_file()
@@ -212,6 +357,7 @@ class Config(object):
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration) new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
self._local_configuration = configuration self._local_configuration = configuration
self.__effective_configuration = new_configuration self.__effective_configuration = new_configuration
self._validate_failover_tags()
return True return True
else: else:
logger.info('No local configuration items changed.') logger.info('No local configuration items changed.')
@@ -219,41 +365,121 @@ class Config(object):
logger.exception('Exception when reloading local configuration from %s', self.config_file) logger.exception('Exception when reloading local configuration from %s', self.config_file)
@staticmethod @staticmethod
def _process_postgresql_parameters(parameters, is_local=False): def _process_postgresql_parameters(parameters: Dict[str, Any], is_local: bool = False) -> Dict[str, Any]:
return {name: value for name, value in (parameters or {}).items() """Process Postgres *parameters*.
if name not in ConfigHandler.CMDLINE_OPTIONS or
not is_local and ConfigHandler.CMDLINE_OPTIONS[name][1](value)}
def _safe_copy_dynamic_configuration(self, dynamic_configuration): .. note::
config = deepcopy(self.__DEFAULT_CONFIG) 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():
if name not in ConfigHandler.CMDLINE_OPTIONS:
pg_params[name] = value
elif not is_local:
validator = ConfigHandler.CMDLINE_OPTIONS[name][1]
if validator(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])
return pg_params
def _safe_copy_dynamic_configuration(self, dynamic_configuration: Dict[str, Any]) -> Dict[str, Any]:
"""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(): for name, value in dynamic_configuration.items():
# allow copying master_start_timeout->primary_start_timeout when the latter isn't in dynamic_configuration
if name in self.__REMAP_KEYS and self.__REMAP_KEYS[name] not in dynamic_configuration:
name = self.__REMAP_KEYS[name]
if name == 'postgresql': if name == 'postgresql':
for name, value in (value or {}).items(): for name, value in (value or EMPTY_DICT).items():
if name == 'parameters': if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value)) config['postgresql'][name].update(self._process_postgresql_parameters(value))
elif name not in ('connect_address', 'proxy_address', 'listen', elif name not in ('connect_address', 'proxy_address', 'listen',
'config_dir', 'data_dir', 'pgpass', 'authentication'): 'config_dir', 'data_dir', 'pgpass', 'authentication'):
config['postgresql'][name] = deepcopy(value) config['postgresql'][name] = deepcopy(value)
elif name == 'standby_cluster': 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']: if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value) config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
if name in ('synchronous_mode', 'synchronous_mode_strict', 'failsafe_mode'): config[name] = int(value)
config[name] = value
else:
config[name] = int(value)
return config return config
@staticmethod @staticmethod
def _build_environment_configuration(): def _build_environment_configuration() -> Dict[str, Any]:
ret = defaultdict(dict) """Get local configuration settings that were specified through environment variables.
def _popenv(name): :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) -> 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) return os.environ.pop(PATRONI_ENV_PREFIX + name.upper(), None)
for param in ('name', 'namespace', 'scope'): for param in ('name', 'namespace', 'scope'):
@@ -261,7 +487,24 @@ class Config(object):
if value: if value:
ret[param] = value ret[param] = value
def _fix_log_env(name, oldname): def _fix_log_env(name: str, oldname: str) -> None:
"""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) value = _popenv(oldname)
name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper() name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper()
if value and name not in os.environ: if value and name not in os.environ:
@@ -270,7 +513,16 @@ class Config(object):
for name, oldname in (('level', 'loglevel'), ('format', 'logformat'), ('dateformat', 'log_datefmt')): for name, oldname in (('level', 'loglevel'), ('format', 'logformat'), ('dateformat', 'log_datefmt')):
_fix_log_env(name, oldname) _fix_log_env(name, oldname)
def _set_section_values(section, params): def _set_section_values(section: str, params: List[str]) -> None:
"""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: for param in params:
value = _popenv(section + '_' + param) value = _popenv(section + '_' + param)
if value: if value:
@@ -278,14 +530,21 @@ class Config(object):
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password', _set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
'cafile', 'ciphers', 'verify_client', 'http_extra_headers', 'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
'https_extra_headers', 'allowlist', 'allowlist_include_members']) 'https_extra_headers', 'allowlist', 'allowlist_include_members',
'request_queue_size'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password']) _set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address', _set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
'config_dir', 'data_dir', 'pgpass', 'bin_dir']) 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size', _set_section_values('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
'dir', 'file_size', 'file_num', 'loggers']) 'max_queue_size', 'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr']) _set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
value = _popenv('POSTGRESQL_BIN_' + binary)
if value:
ret['postgresql'].setdefault('bin_name', {})[binary] = value
# 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')): for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
value = ret.get(first, {}).pop(second, None) value = ret.get(first, {}).pop(second, None)
if value: if value:
@@ -293,14 +552,22 @@ class Config(object):
if value is not None: if value is not None:
ret[first][second] = value ret[first][second] = value
for second in ('max_queue_size', 'file_size', 'file_num'): for first, params in (('restapi', ('request_queue_size',)),
value = ret.get('log', {}).pop(second, None) ('log', ('max_queue_size', 'file_size', 'file_num'))):
if value: for second in params:
value = parse_int(value) value = ret.get(first, {}).pop(second, None)
if value is not None: if value:
ret['log'][second] = value value = parse_int(value)
if value is not None:
ret[first][second] = value
def _parse_list(value): def _parse_list(value: str) -> 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): if not (value.strip().startswith('-') or '[' in value):
value = '[{0}]'.format(value) value = '[{0}]'.format(value)
try: try:
@@ -316,7 +583,19 @@ class Config(object):
if value: if value:
ret[first][second] = value ret[first][second] = value
def _parse_dict(value): 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('{'): if not value.strip().startswith('{'):
value = '{{{0}}}'.format(value) value = '{{{0}}}'.format(value)
try: try:
@@ -325,7 +604,12 @@ class Config(object):
logger.exception('Exception when parsing dict %s', value) logger.exception('Exception when parsing dict %s', value)
return None 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: for second in params:
value = ret.get(first, {}).pop(second, None) value = ret.get(first, {}).pop(second, None)
if value: if value:
@@ -333,17 +617,25 @@ class Config(object):
if value: if value:
ret[first][second] = value ret[first][second] = value
def _get_auth(name, params=None): def _get_auth(name: str, params: Collection[str] = _AUTH_ALLOWED_PARAMETERS[:2]) -> Dict[str, str]:
ret = {} """Get authorization related environment variables *params* from section *name*.
for param in params or _AUTH_ALLOWED_PARAMETERS[:2]:
: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:
value = _popenv(name + '_' + param) value = _popenv(name + '_' + param)
if value: if value:
ret[param] = value ret[param] = value
return ret return ret
restapi_auth = _get_auth('restapi') for section in ('ctl', 'restapi'):
if restapi_auth: auth = _get_auth(section)
ret['restapi']['authentication'] = restapi_auth if auth:
ret[section]['authentication'] = auth
authentication = {} authentication = {}
for user_type in ('replication', 'superuser', 'rewind'): for user_type in ('replication', 'superuser', 'rewind'):
@@ -357,13 +649,14 @@ class Config(object):
for param in list(os.environ.keys()): for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX): if param.startswith(PATRONI_ENV_PREFIX):
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..) # PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
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', if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'SRV_SUFFIX', 'URL', 'PROXY',
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY', 'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME', 'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD', 'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE') and name: 'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL', 'AUTH_DATA') and name:
value = os.environ.pop(param) value = os.environ.pop(param)
if name == 'CITUS': if name == 'CITUS':
if suffix == 'GROUP': if suffix == 'GROUP':
@@ -374,7 +667,7 @@ class Config(object):
value = value and parse_int(value) value = value and parse_int(value)
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'): elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
value = value and _parse_list(value) value = value and _parse_list(value)
elif suffix in ('LABELS', 'SET_ACLS'): elif suffix in ('LABELS', 'SET_ACLS', 'AUTH_DATA'):
value = _parse_dict(value) value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'): elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
value = parse_bool(value) value = parse_bool(value)
@@ -387,14 +680,14 @@ class Config(object):
users = {} users = {}
for param in list(os.environ.keys()): for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX): if param.startswith(PATRONI_ENV_PREFIX):
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,...> # PATRONI_<username>_PASSWORD=<password>, PATRONI_<username>_OPTIONS=<option1,option2,...>
# CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>' # CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>'
if name and suffix == 'PASSWORD': if name and suffix == 'PASSWORD':
password = os.environ.pop(param) password = os.environ.pop(param)
if password: if password:
users[name] = {'password': 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) options = options and _parse_list(options)
if options: if options:
users[name]['options'] = options users[name]['options'] = options
@@ -403,7 +696,18 @@ class Config(object):
return ret return ret
def _build_effective_configuration(self, dynamic_configuration, local_configuration): def _build_effective_configuration(self, dynamic_configuration: Dict[str, Any],
local_configuration: Dict[str, Union[Dict[str, Any], Any]]) -> Dict[str, Any]:
"""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) config = self._safe_copy_dynamic_configuration(dynamic_configuration)
for name, value in local_configuration.items(): for name, value in local_configuration.items():
if name == 'citus': # remove invalid citus configuration if name == 'citus': # remove invalid citus configuration
@@ -419,9 +723,10 @@ class Config(object):
elif name not in config or name in ['watchdog']: elif name not in config or name in ['watchdog']:
config[name] = deepcopy(value) if value else {} config[name] = deepcopy(value) if value else {}
# restapi server expects to get restapi.auth = 'username:password' # restapi server expects to get restapi.auth = 'username:password' and similarly for `ctl`
if 'restapi' in config and 'authentication' in config['restapi']: for section in ('ctl', 'restapi'):
config['restapi']['auth'] = '{username}:{password}'.format(**config['restapi']['authentication']) if section in config and 'authentication' in config[section]:
config[section]['auth'] = '{username}:{password}'.format(**config[section]['authentication'])
# special treatment for old config # special treatment for old config
@@ -458,10 +763,6 @@ class Config(object):
'name', 'name',
'scope', 'scope',
'retry_timeout', 'retry_timeout',
'synchronous_mode',
'synchronous_mode_strict',
'synchronous_node_count',
'maximum_lag_on_syncnode',
'citus' 'citus'
) )
@@ -469,14 +770,69 @@ class Config(object):
return config return config
def get(self, key, default=None): 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) return self.__effective_configuration.get(key, default)
def __contains__(self, key): 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 return key in self.__effective_configuration
def __getitem__(self, key): 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] return self.__effective_configuration[key]
def copy(self): 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) return deepcopy(self.__effective_configuration)
def _validate_failover_tags(self) -> None:
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
.. 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)
"""
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}')
+1214 -296
View File
File diff suppressed because it is too large Load Diff
+26 -33
View File
@@ -6,15 +6,32 @@ Currently it is only used for the main "Thread" of ``patroni`` and ``patroni_raf
from __future__ import print_function from __future__ import print_function
import abc import abc
import argparse
import os import os
import signal import signal
import sys import sys
from threading import Lock from threading import Lock
from typing import Any, Optional, Type from typing import Any, Optional, Type, TYPE_CHECKING
from .config import Config if TYPE_CHECKING: # pragma: no cover
from .validator import Schema from .config import Config
def get_base_arg_parser() -> argparse.ArgumentParser:
"""Create a basic argument parser with the arguments used for both patroni and raft controller daemon.
:returns: 'argparse.ArgumentParser' object
"""
from .config import Config
from .version import __version__
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
return parser
class AbstractPatroniDaemon(abc.ABC): class AbstractPatroniDaemon(abc.ABC):
@@ -30,7 +47,7 @@ class AbstractPatroniDaemon(abc.ABC):
:ivar config: configuration options for this daemon. :ivar config: configuration options for this daemon.
""" """
def __init__(self, config: Config) -> None: def __init__(self, config: 'Config') -> None:
"""Set up signal handlers, logging handler and configuration. """Set up signal handlers, logging handler and configuration.
:param config: configuration options for this daemon. :param config: configuration options for this daemon.
@@ -94,7 +111,7 @@ class AbstractPatroniDaemon(abc.ABC):
with self._sigterm_lock: with self._sigterm_lock:
return self._received_sigterm return self._received_sigterm
def reload_config(self, sighup: Optional[bool] = False, local: Optional[bool] = False) -> None: def reload_config(self, sighup: bool = False, local: Optional[bool] = False) -> None:
"""Reload configuration. """Reload configuration.
:param sighup: if it is related to a SIGHUP signal. :param sighup: if it is related to a SIGHUP signal.
@@ -140,41 +157,17 @@ class AbstractPatroniDaemon(abc.ABC):
self.logger.shutdown() self.logger.shutdown()
def abstract_main(cls: Type[AbstractPatroniDaemon], validator: Optional[Schema] = None) -> None: def abstract_main(cls: Type[AbstractPatroniDaemon], configfile: str) -> None:
"""Create the main entry point of a given daemon process. """Create the main entry point of a given daemon process.
Expose a basic argument parser, parse the command-line arguments, and run the given daemon process.
:param cls: a class that should inherit from :class:`AbstractPatroniDaemon`. :param cls: a class that should inherit from :class:`AbstractPatroniDaemon`.
:param validator: used to validate the daemon configuration schema, if requested by the user through :param configfile:
``--validate-config`` CLI option.
""" """
import argparse
from .config import Config, ConfigParseError from .config import Config, ConfigParseError
from .version import __version__
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
if validator:
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
args = parser.parse_args()
validate_config = validator and args.validate_config
try: try:
if validate_config: config = Config(configfile)
Config(args.configfile, validator=validator)
sys.exit()
config = Config(args.configfile)
except ConfigParseError as e: except ConfigParseError as e:
if e.value: sys.exit(e.value)
print(e.value, file=sys.stderr)
if not validate_config:
parser.print_help()
sys.exit(1)
controller = cls(config) controller = cls(config)
try: try:
+1451 -563
View File
File diff suppressed because it is too large Load Diff
+189 -140
View File
@@ -8,16 +8,20 @@ import ssl
import time import time
import urllib3 import urllib3
from collections import defaultdict, namedtuple from collections import defaultdict
from consul import ConsulException, NotFound, base from consul import ConsulException, NotFound, base
from http.client import HTTPException from http.client import HTTPException
from urllib3.exceptions import HTTPError from urllib3.exceptions import HTTPError
from urllib.parse import urlencode, urlparse, quote from urllib.parse import urlencode, urlparse, quote
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\ from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re TimelineHistory, ReturnFalseException, catch_return_false_exception
from ..exceptions import DCSError from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,12 +42,17 @@ class InvalidSession(ConsulException):
"""invalid session""" """invalid session"""
Response = namedtuple('Response', 'code,headers,body,content') class Response(NamedTuple):
code: int
headers: Union[Mapping[str, str], Mapping[bytes, bytes], None]
body: str
content: bytes
class HTTPClient(object): class HTTPClient(object):
def __init__(self, host='127.0.0.1', port=8500, token=None, scheme='http', verify=True, cert=None, ca_cert=None): def __init__(self, host: str = '127.0.0.1', port: int = 8500, token: Optional[str] = None, scheme: str = 'http',
verify: bool = True, cert: Optional[str] = None, ca_cert: Optional[str] = None) -> None:
self.token = token self.token = token
self._read_timeout = 10 self._read_timeout = 10
self.base_uri = uri(scheme, (host, port)) self.base_uri = uri(scheme, (host, port))
@@ -59,23 +68,23 @@ class HTTPClient(object):
if ca_cert: if ca_cert:
kwargs['ca_certs'] = ca_cert kwargs['ca_certs'] = ca_cert
kwargs['cert_reqs'] = ssl.CERT_REQUIRED if verify or ca_cert else ssl.CERT_NONE kwargs['cert_reqs'] = ssl.CERT_REQUIRED if verify or ca_cert else ssl.CERT_NONE
self.http = urllib3.PoolManager(num_pools=10, maxsize=10, **kwargs) self.http = urllib3.PoolManager(num_pools=10, maxsize=10, headers={}, **kwargs)
self._ttl = None self._ttl = 30
def set_read_timeout(self, timeout): def set_read_timeout(self, timeout: float) -> None:
self._read_timeout = timeout/3.0 self._read_timeout = timeout / 3.0
@property @property
def ttl(self): def ttl(self) -> int:
return self._ttl return self._ttl
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> bool:
ret = self._ttl != ttl ret = self._ttl != ttl
self._ttl = ttl self._ttl = ttl
return ret return ret
@staticmethod @staticmethod
def response(response): def response(response: urllib3.response.HTTPResponse) -> Response:
content = response.data content = response.data
body = content.decode('utf-8') body = content.decode('utf-8')
if response.status == 500: if response.status == 500:
@@ -88,14 +97,19 @@ class HTTPClient(object):
raise ConsulInternalError(msg) raise ConsulInternalError(msg)
return Response(response.status, response.headers, body, content) return Response(response.status, response.headers, body, content)
def uri(self, path, params=None): def uri(self, path: str,
params: Union[None, Dict[str, Any], List[Tuple[str, Any]], Tuple[Tuple[str, Any], ...]] = None) -> str:
return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '') return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '')
def __getattr__(self, method): def __getattr__(self, method: str) -> Callable[[Callable[[Response], Union[bool, Any, Tuple[str, Any]]],
str, Union[None, Dict[str, Any], List[Tuple[str, Any]]],
str, Optional[Dict[str, str]]], Union[bool, Any, Tuple[str, Any]]]:
if method not in ('get', 'post', 'put', 'delete'): if method not in ('get', 'post', 'put', 'delete'):
raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method)) raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method))
def wrapper(callback, path, params=None, data='', headers=None): def wrapper(callback: Callable[[Response], Union[bool, Any, Tuple[str, Any]]], path: str,
params: Union[None, Dict[str, Any], List[Tuple[str, Any]]] = None, data: str = '',
headers: Optional[Dict[str, str]] = None) -> Union[bool, Any, Tuple[str, Any]]:
# python-consul doesn't allow to specify ttl smaller then 10 seconds # python-consul doesn't allow to specify ttl smaller then 10 seconds
# because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack... # because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack...
if method == 'put' and path == '/v1/session/create': if method == 'put' and path == '/v1/session/create':
@@ -106,14 +120,14 @@ class HTTPClient(object):
data = data[:-1] + ', ' + ttl + '}' data = data[:-1] + ', ' + ttl + '}'
if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params
params = {k: v for k, v in params} params = {k: v for k, v in params}
kwargs = {'retries': 0, 'preload_content': False, 'body': data} kwargs: Dict[str, Any] = {'retries': 0, 'preload_content': False, 'body': data}
if method == 'get' and isinstance(params, dict) and 'index' in params: if method == 'get' and isinstance(params, dict) and 'index' in params:
timeout = float(params['wait'][:-1]) if 'wait' in params else 300 timeout = float(params['wait'][:-1]) if 'wait' in params else 300
# According to the documentation a small random amount of additional wait time is added to the # According to the documentation a small random amount of additional wait time is added to the
# supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds # supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds
# up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a # up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a
# response rather read timeout we will add to the timeout a slightly bigger value. # response rather read timeout we will add to the timeout a slightly bigger value.
kwargs['timeout'] = timeout + max(timeout/15.0, 1) kwargs['timeout'] = timeout + max(timeout / 15.0, 1)
else: else:
kwargs['timeout'] = self._read_timeout kwargs['timeout'] = self._read_timeout
kwargs['headers'] = (headers or {}).copy() kwargs['headers'] = (headers or {}).copy()
@@ -127,13 +141,43 @@ class HTTPClient(object):
class ConsulClient(base.Consul): class ConsulClient(base.Consul):
def __init__(self, *args, **kwargs): 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._cert = kwargs.pop('cert', None)
self._ca_cert = kwargs.pop('ca_cert', None) self._ca_cert = kwargs.pop('ca_cert', None)
self.token = kwargs.get('token') self.token = kwargs.get('token')
super(ConsulClient, self).__init__(*args, **kwargs) super(ConsulClient, self).__init__(*args, **kwargs)
def http_connect(self, *args, **kwargs): def http_connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args))) kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args)))
if self._cert: if self._cert:
kwargs['cert'] = self._cert kwargs['cert'] = self._cert
@@ -143,17 +187,17 @@ class ConsulClient(base.Consul):
kwargs['token'] = self.token kwargs['token'] = self.token
return HTTPClient(**kwargs) return HTTPClient(**kwargs)
def connect(self, *args, **kwargs): def connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
return self.http_connect(*args, **kwargs) return self.http_connect(*args, **kwargs)
def reload_config(self, config): def reload_config(self, config: Dict[str, Any]) -> None:
self.http.token = self.token = config.get('token') self.http.token = self.token = config.get('token')
self.consistency = config.get('consistency', 'default') self.consistency = config.get('consistency', 'default')
self.dc = config.get('dc') self.dc = config.get('dc')
def catch_consul_errors(func): def catch_consul_errors(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout): except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
@@ -161,24 +205,25 @@ def catch_consul_errors(func):
return wrapper return wrapper
def force_if_last_failed(func): def force_if_last_failed(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
if wrapper.last_result is False: if getattr(wrapper, 'last_result', None) is False:
kwargs['force'] = True kwargs['force'] = True
wrapper.last_result = func(*args, **kwargs) last_result = func(*args, **kwargs)
return wrapper.last_result setattr(wrapper, 'last_result', last_result)
return last_result
wrapper.last_result = None setattr(wrapper, 'last_result', None)
return wrapper return wrapper
def service_name_from_scope_name(scope_name): def service_name_from_scope_name(scope_name: str) -> str:
"""Translate scope name to service name which can be used in dns. """Translate scope name to service name which can be used in dns.
230 = 253 - len('replica.') - len('.service.consul') 230 = 253 - len('replica.') - len('.service.consul')
""" """
def replace_char(match): def replace_char(match: Any) -> str:
c = match.group(0) c = match.group(0)
return '-' if c in '. _' else "u{:04d}".format(ord(c)) return '-' if c in '. _' else "u{:04d}".format(ord(c))
@@ -188,8 +233,8 @@ def service_name_from_scope_name(scope_name):
class Consul(AbstractDCS): class Consul(AbstractDCS):
def __init__(self, config): def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Consul, self).__init__(config) super(Consul, self).__init__(config, mpp)
self._base_path = self._base_path[1:] self._base_path = self._base_path[1:]
self._scope = config['scope'] self._scope = config['scope']
self._session = None self._session = None
@@ -198,9 +243,9 @@ class Consul(AbstractDCS):
retry_exceptions=(ConsulInternalError, HTTPException, retry_exceptions=(ConsulInternalError, HTTPException,
HTTPError, socket.error, socket.timeout)) HTTPError, socket.error, socket.timeout))
kwargs = {}
if 'url' in config: if 'url' in config:
r = urlparse(config['url']) url: str = config['url']
r = urlparse(url)
config.update({'scheme': r.scheme, 'host': r.hostname, 'port': r.port or 8500}) config.update({'scheme': r.scheme, 'host': r.hostname, 'port': r.port or 8500})
elif 'host' in config: elif 'host' in config:
host, port = split_host_port(config.get('host', '127.0.0.1:8500'), 8500) host, port = split_host_port(config.get('host', '127.0.0.1:8500'), 8500)
@@ -215,7 +260,7 @@ class Consul(AbstractDCS):
config['cert'] = (config['cert'], config['key']) config['cert'] = (config['cert'], config['key'])
config_keys = ('host', 'port', 'token', 'scheme', 'cert', 'ca_cert', 'dc', 'consistency') config_keys = ('host', 'port', 'token', 'scheme', 'cert', 'ca_cert', 'dc', 'consistency')
kwargs = {p: config.get(p) for p in config_keys if config.get(p)} kwargs: Dict[str, Any] = {p: config.get(p) for p in config_keys if config.get(p)}
verify = config.get('verify') verify = config.get('verify')
if not isinstance(verify, bool): if not isinstance(verify, bool):
@@ -240,10 +285,10 @@ class Consul(AbstractDCS):
self.create_session() self.create_session()
self._previous_loop_token = self._client.token self._previous_loop_token = self._client.token
def retry(self, *args, **kwargs): def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
return self._retry.copy()(*args, **kwargs) return self._retry.copy()(method, *args, **kwargs)
def create_session(self): def create_session(self) -> None:
while not self._session: while not self._session:
try: try:
self.refresh_session() self.refresh_session()
@@ -251,13 +296,14 @@ class Consul(AbstractDCS):
logger.info('waiting on consul') logger.info('waiting on consul')
time.sleep(5) time.sleep(5)
def reload_config(self, config): def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
super(Consul, self).reload_config(config) super(Consul, self).reload_config(config)
consul_config = config.get('consul', {}) consul_config = config.get('consul', {})
self._client.reload_config(consul_config) self._client.reload_config(consul_config)
self._previous_loop_service_tags = self._service_tags self._previous_loop_service_tags = self._service_tags
self._service_tags = sorted(consul_config.get('service_tags', [])) self._service_tags: List[str] = consul_config.get('service_tags', [])
self._service_tags.sort()
should_register_service = consul_config.get('register_service', False) should_register_service = consul_config.get('register_service', False)
if should_register_service and not self._register_service: if should_register_service and not self._register_service:
@@ -266,29 +312,30 @@ class Consul(AbstractDCS):
self._previous_loop_register_service = self._register_service self._previous_loop_register_service = self._register_service
self._register_service = should_register_service self._register_service = should_register_service
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x if self._client.http.set_ttl(ttl / 2.0): # Consul multiplies the TTL by 2x
self._session = None self._session = None
self.__do_not_watch = True self.__do_not_watch = True
return None
@property @property
def ttl(self): def ttl(self) -> int:
return self._client.http.ttl * 2 # we multiply the value by 2 because it was divided in the `set_ttl()` method return self._client.http.ttl * 2 # we multiply the value by 2 because it was divided in the `set_ttl()` method
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self._retry.deadline = retry_timeout self._retry.deadline = retry_timeout
self._client.http.set_read_timeout(retry_timeout) self._client.http.set_read_timeout(retry_timeout)
def adjust_ttl(self): def adjust_ttl(self) -> None:
try: try:
settings = self._client.agent.self() settings = self._client.agent.self()
min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000)/1000000000.0 min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000) / 1000000000.0
logger.warning('Changing Session TTL from %s to %s', self._client.http.ttl, min_ttl) logger.warning('Changing Session TTL from %s to %s', self._client.http.ttl, min_ttl)
self._client.http.set_ttl(min_ttl) self._client.http.set_ttl(min_ttl)
except Exception: except Exception:
logger.exception('adjust_ttl') logger.exception('adjust_ttl')
def _do_refresh_session(self, force=False): def _do_refresh_session(self, force: bool = False) -> bool:
""":returns: `!True` if it had to create new session""" """:returns: `!True` if it had to create new session"""
if not force and self._session and self._last_session_refresh + self._loop_wait > time.time(): if not force and self._session and self._last_session_refresh + self._loop_wait > time.time():
return False return False
@@ -312,7 +359,7 @@ class Consul(AbstractDCS):
self._last_session_refresh = time.time() self._last_session_refresh = time.time()
return ret return ret
def refresh_session(self): def refresh_session(self) -> bool:
try: try:
return self.retry(self._do_refresh_session) return self.retry(self._do_refresh_session)
except (ConsulException, RetryFailedError): except (ConsulException, RetryFailedError):
@@ -320,10 +367,10 @@ class Consul(AbstractDCS):
raise ConsulError('Failed to renew/create session') raise ConsulError('Failed to renew/create session')
@staticmethod @staticmethod
def member(node): def member(node: Dict[str, str]) -> Member:
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value']) return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
def _cluster_from_nodes(self, nodes): def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['Value'] initialize = initialize and initialize['Value']
@@ -337,23 +384,8 @@ class Consul(AbstractDCS):
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value']) history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
# get last known leader lsn and slots # get last known leader lsn and slots
status = nodes.get(self._STATUS) status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
if status: status = Status.from_node(status and status['Value'])
try:
status = json.loads(status['Value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['Value']
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -382,40 +414,57 @@ class Consul(AbstractDCS):
except Exception: except Exception:
failsafe = None 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): @property
_, results = self.retry(self._client.kv.get, path, recurse=True) def _consistency(self) -> str:
return 'consistent' if self._ctl else self._client.consistency
def _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: if results is None:
raise NotFound return Cluster.empty()
nodes = {} nodes: Dict[str, Dict[str, Any]] = {}
for node in results: for node in results:
node['Value'] = (node['Value'] or b'').decode('utf-8') node['Value'] = (node['Value'] or b'').decode('utf-8')
nodes[node['Key'][len(path):]] = node nodes[node['Key'][len(path):]] = node
return self._cluster_from_nodes(nodes) return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path): def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
_, results = self.retry(self._client.kv.get, path, recurse=True) """Load and build all PostgreSQL clusters from a single MPP cluster.
clusters = defaultdict(dict)
: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, Dict[str, Any]]] = defaultdict(dict)
for node in results or []: for node in results or []:
key = node['Key'][len(path):].split('/', 1) key = node['Key'][len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]): if len(key) == 2 and self._mpp.group_re.match(key[0]):
node['Value'] = (node['Value'] or b'').decode('utf-8') node['Value'] = (node['Value'] or b'').decode('utf-8')
clusters[int(key[0])][key[1]] = node clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
try: try:
return loader(path) return loader(path)
except NotFound:
return Cluster.empty()
except Exception: except Exception:
logger.exception('get_cluster') logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly') raise ConsulError('Consul is not responding properly')
@catch_consul_errors @catch_consul_errors
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
cluster = self.cluster cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False) member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
@@ -447,30 +496,32 @@ class Consul(AbstractDCS):
logger.exception('touch_member') logger.exception('touch_member')
return False return False
def _set_service_name(self): def _set_service_name(self) -> None:
self._service_name = service_name_from_scope_name(self._scope) self._service_name = service_name_from_scope_name(self._scope)
if self._scope != self._service_name: if self._scope != self._service_name:
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope) logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope)
@catch_consul_errors @catch_consul_errors
def register_service(self, service_name, **kwargs): def register_service(self, service_name: str, **kwargs: Any) -> bool:
logger.info('Register service %s, params %s', service_name, kwargs) logger.info('Register service %s, params %s', service_name, kwargs)
return self._client.agent.service.register(service_name, **kwargs) return self._client.agent.service.register(service_name, **kwargs)
@catch_consul_errors @catch_consul_errors
def deregister_service(self, service_id): def deregister_service(self, service_id: str) -> bool:
logger.info('Deregister service %s', service_id) logger.info('Deregister service %s', service_id)
# service_id can contain special characters, but is used as part of uri in deregister request # service_id can contain special characters, but is used as part of uri in deregister request
service_id = quote(service_id) service_id = quote(service_id)
return self._client.agent.service.deregister(service_id) return self._client.agent.service.deregister(service_id)
def _update_service(self, data): def _update_service(self, data: Dict[str, Any]) -> Optional[bool]:
service_name = self._service_name service_name = self._service_name
role = data['role'].replace('_', '-') role = data['role'].replace('_', '-')
state = data['state'] state = data['state']
api_parts = urlparse(data['api_url']) api_url: str = data['api_url']
api_parts = urlparse(api_url)
api_parts = api_parts._replace(path='/{0}'.format(role)) api_parts = api_parts._replace(path='/{0}'.format(role))
conn_parts = urlparse(data['conn_url']) conn_url: str = data['conn_url']
conn_parts = urlparse(conn_url)
check = base.Check.http(api_parts.geturl(), self._service_check_interval, check = base.Check.http(api_parts.geturl(), self._service_check_interval,
deregister='{0}s'.format(self._client.http.ttl * 10)) deregister='{0}s'.format(self._client.http.ttl * 10))
if self._service_check_tls_server_name is not None: if self._service_check_tls_server_name is not None:
@@ -506,7 +557,7 @@ class Consul(AbstractDCS):
logger.warning('Could not register service: unknown role type %s', role) logger.warning('Could not register service: unknown role type %s', role)
@force_if_last_failed @force_if_last_failed
def update_service(self, old_data, new_data, force=False): def update_service(self, old_data: Dict[str, Any], new_data: Dict[str, Any], force: bool = False) -> Optional[bool]:
update = False update = False
for key in ['role', 'api_url', 'conn_url', 'state']: for key in ['role', 'api_url', 'conn_url', 'state']:
@@ -523,30 +574,29 @@ class Consul(AbstractDCS):
): ):
return self._update_service(new_data) return self._update_service(new_data)
def _do_attempt_to_acquire_leader(self, retry): def _do_attempt_to_acquire_leader(self, retry: Retry) -> bool:
try: try:
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session) return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
except InvalidSession: except InvalidSession:
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
self._session = None self._session = None
retry.deadline = retry.stoptime - time.time()
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(self._do_refresh_session)
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
if retry.deadline < 1:
raise ConsulError('_do_attempt_to_acquire_leader timeout')
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session) return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
@catch_return_false_exception @catch_return_false_exception
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
retry = self._retry.copy() retry = self._retry.copy()
self._run_and_handle_exceptions(self._do_refresh_session, retry=retry) self._run_and_handle_exceptions(self._do_refresh_session, retry=retry)
retry.deadline = retry.stoptime - time.time() retry.ensure_deadline(1, ConsulError('attempt_to_acquire_leader timeout'))
if retry.deadline < 1:
raise ConsulError('attempt_to_acquire_leader timeout')
ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None) ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None)
if not ret: if not ret:
@@ -554,31 +604,31 @@ class Consul(AbstractDCS):
return ret return ret
def take_leader(self): def take_leader(self) -> bool:
return self.attempt_to_acquire_leader() return self.attempt_to_acquire_leader()
@catch_consul_errors @catch_consul_errors
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.kv.put(self.failover_path, value, cas=index) return self._client.kv.put(self.failover_path, value, cas=version)
@catch_consul_errors @catch_consul_errors
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.kv.put(self.config_path, value, cas=index) return self._client.kv.put(self.config_path, value, cas=version)
@catch_consul_errors @catch_consul_errors
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._client.kv.put(self.leader_optime_path, last_lsn) return self._client.kv.put(self.leader_optime_path, last_lsn)
@catch_consul_errors @catch_consul_errors
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._client.kv.put(self.status_path, value) return self._client.kv.put(self.status_path, value)
@catch_consul_errors @catch_consul_errors
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._client.kv.put(self.failsafe_path, value) return self._client.kv.put(self.failsafe_path, value)
@staticmethod @staticmethod
def _run_and_handle_exceptions(method, *args, **kwargs): def _run_and_handle_exceptions(method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
retry = kwargs.pop('retry', None) retry = kwargs.pop('retry', None)
try: try:
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs) return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
@@ -588,73 +638,72 @@ class Consul(AbstractDCS):
raise ReturnFalseException raise ReturnFalseException
@catch_return_false_exception @catch_return_false_exception
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
retry = self._retry.copy() retry = self._retry.copy()
self._run_and_handle_exceptions(self._do_refresh_session, True, retry=retry) self._run_and_handle_exceptions(self._do_refresh_session, True, retry=retry)
if self._session: if self._session and leader.session != self._session:
cluster = self.cluster retry.ensure_deadline(1, ConsulError('update_leader timeout'))
leader_session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
if leader_session != self._session:
retry.deadline = retry.stoptime - time.time()
if retry.deadline < 1:
raise ConsulError('update_leader timeout')
logger.warning('Recreating the leader key due to session mismatch')
if cluster.leader:
self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=cluster.leader.index)
retry.deadline = retry.stoptime - time.time() logger.warning('Recreating the leader key due to session mismatch')
if retry.deadline < 0.5: self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=leader.version)
raise ConsulError('update_leader timeout')
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path, retry.ensure_deadline(0.5, ConsulError('update_leader timeout'))
self._name, acquire=self._session)
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
return bool(self._session) return bool(self._session)
@catch_consul_errors @catch_consul_errors
def initialize(self, create_new=True, sysid=''): def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
kwargs = {'cas': 0} if create_new else {} kwargs = {'cas': 0} if create_new else {}
return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs) return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs)
@catch_consul_errors @catch_consul_errors
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self.retry(self._client.kv.delete, self.initialize_path) return self.retry(self._client.kv.delete, self.initialize_path)
@catch_consul_errors @catch_consul_errors
def delete_cluster(self): def delete_cluster(self) -> bool:
return self.retry(self._client.kv.delete, self.client_path(''), recurse=True) return self.retry(self._client.kv.delete, self.client_path(''), recurse=True)
@catch_consul_errors @catch_consul_errors
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._client.kv.put(self.history_path, value) return self._client.kv.put(self.history_path, value)
@catch_consul_errors @catch_consul_errors
def _delete_leader(self): def _delete_leader(self, leader: Leader) -> bool:
cluster = self.cluster return self._client.kv.delete(self.leader_path, cas=int(leader.version))
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index)
@catch_consul_errors @catch_consul_errors
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self.retry(self._client.kv.put, self.sync_path, value, cas=index) retry = self._retry.copy()
ret = retry(self._client.kv.put, self.sync_path, value, cas=version)
if ret: # We have no other choise, only read after write :(
if not retry.ensure_deadline(0.5):
return False
_, ret = self.retry(self._client.kv.get, self.sync_path, consistency='consistent')
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
return ret['ModifyIndex']
return False
@catch_consul_errors @catch_consul_errors
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self.retry(self._client.kv.delete, self.sync_path, cas=index) return self.retry(self._client.kv.delete, self.sync_path, cas=version)
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
self._last_session_refresh = 0 self._last_session_refresh = 0
if self.__do_not_watch: if self.__do_not_watch:
self.__do_not_watch = False self.__do_not_watch = False
return True return True
if leader_index: if leader_version:
end_time = time.time() + timeout end_time = time.time() + timeout
while timeout >= 1: while timeout >= 1:
try: try:
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's') idx, _ = self._client.kv.get(self.leader_path, index=leader_version, wait=str(timeout) + 's')
return str(idx) != str(leader_index) return str(idx) != str(leader_version)
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout): except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
logger.exception('watch') logger.exception('watch')
+201 -155
View File
@@ -16,16 +16,19 @@ from dns import resolver
from http.client import HTTPException from http.client import HTTPException
from queue import Queue from queue import Queue
from threading import Thread from threading import Thread
from typing import List, Optional from typing import Any, Callable, Collection, Dict, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib3 import Timeout from urllib3 import Timeout
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\ from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re TimelineHistory, ReturnFalseException, catch_return_false_exception
from ..exceptions import DCSError from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get from ..request import get as requests_get
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,18 +41,21 @@ class EtcdError(DCSError):
pass pass
_AddrInfo = Tuple[socket.AddressFamily, socket.SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]]
class DnsCachingResolver(Thread): class DnsCachingResolver(Thread):
def __init__(self, cache_time=600.0, cache_fail_time=30.0): def __init__(self, cache_time: float = 600.0, cache_fail_time: float = 30.0) -> None:
super(DnsCachingResolver, self).__init__() super(DnsCachingResolver, self).__init__()
self._cache = {} self._cache: Dict[Tuple[str, int], Tuple[float, List[_AddrInfo]]] = {}
self._cache_time = cache_time self._cache_time = cache_time
self._cache_fail_time = cache_fail_time self._cache_fail_time = cache_fail_time
self._resolve_queue = Queue() self._resolve_queue: Queue[Tuple[Tuple[str, int], int]] = Queue()
self.daemon = True self.daemon = True
self.start() self.start()
def run(self): def run(self) -> None:
while True: while True:
(host, port), attempt = self._resolve_queue.get() (host, port), attempt = self._resolve_queue.get()
response = self._do_resolve(host, port) response = self._do_resolve(host, port)
@@ -60,7 +66,7 @@ class DnsCachingResolver(Thread):
self.resolve_async(host, port, attempt + 1) self.resolve_async(host, port, attempt + 1)
time.sleep(1) time.sleep(1)
def resolve(self, host, port): def resolve(self, host: str, port: int) -> List[_AddrInfo]:
current_time = time.time() current_time = time.time()
cached_time, response = self._cache.get((host, port), (0, [])) cached_time, response = self._cache.get((host, port), (0, []))
time_passed = current_time - cached_time time_passed = current_time - cached_time
@@ -71,14 +77,14 @@ class DnsCachingResolver(Thread):
response = new_response response = new_response
return response return response
def resolve_async(self, host, port, attempt=0): def resolve_async(self, host: str, port: int, attempt: int = 0) -> None:
self._resolve_queue.put(((host, port), attempt)) self._resolve_queue.put(((host, port), attempt))
def remove(self, host, port): def remove(self, host: str, port: int) -> None:
self._cache.pop((host, port), None) self._cache.pop((host, port), None)
@staticmethod @staticmethod
def _do_resolve(host, port): def _do_resolve(host: str, port: int) -> List[_AddrInfo]:
try: try:
return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP) return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
except Exception as e: except Exception as e:
@@ -88,13 +94,15 @@ class DnsCachingResolver(Thread):
class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client): class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
def __init__(self, config, dns_resolver, cache_ttl=300): ERROR_CLS: Type[Exception]
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
self._dns_resolver = dns_resolver self._dns_resolver = dns_resolver
self.set_machines_cache_ttl(cache_ttl) self.set_machines_cache_ttl(cache_ttl)
self._machines_cache_updated = 0 self._machines_cache_updated = 0
args = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'username', 'password', kwargs = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'version_prefix',
'cert', 'ca_cert') if config.get(p)} 'username', 'password', 'cert', 'ca_cert') if config.get(p)}
super(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **args) super(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **kwargs)
# For some reason python3-etcd on debian and ubuntu are not based on the latest version # For some reason python3-etcd on debian and ubuntu are not based on the latest version
# Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied # Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied
self.http.connection_pool_kw.pop('ssl_version', None) self.http.connection_pool_kw.pop('ssl_version', None)
@@ -106,7 +114,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
self._read_options.add('retry') self._read_options.add('retry')
self._del_conditions.add('retry') self._del_conditions.add('retry')
def _calculate_timeouts(self, etcd_nodes, timeout=None): def _calculate_timeouts(self, etcd_nodes: int, timeout: Optional[float] = None) -> Tuple[int, float, int]:
"""Calculate a request timeout and number of retries per single etcd node. """Calculate a request timeout and number of retries per single etcd node.
In case if the timeout per node is too small (less than one second) we will reduce the number of nodes. In case if the timeout per node is too small (less than one second) we will reduce the number of nodes.
For the cluster with only one node we will try to do 2 retries. For the cluster with only one node we will try to do 2 retries.
@@ -133,38 +141,39 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
return etcd_nodes, per_node_timeout, per_node_retries - 1 return etcd_nodes, per_node_timeout, per_node_retries - 1
def reload_config(self, config): def reload_config(self, config: Dict[str, Any]) -> None:
self.username = config.get('username') self.username = config.get('username')
self.password = config.get('password') self.password = config.get('password')
def _get_headers(self): def _get_headers(self) -> Dict[str, str]:
basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None
return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT) return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
def _prepare_common_parameters(self, etcd_nodes, timeout=None): def _prepare_common_parameters(self, etcd_nodes: int, timeout: Optional[float] = None) -> Dict[str, Any]:
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect, 'preload_content': False} kwargs: Dict[str, Any] = {'headers': self._get_headers(),
'redirect': self.allow_redirect, 'preload_content': False}
if timeout is not None: if timeout is not None:
kwargs.update(retries=0, timeout=timeout) kwargs.update(retries=0, timeout=timeout)
else: else:
_, per_node_timeout, per_node_retries = self._calculate_timeouts(etcd_nodes) _, per_node_timeout, per_node_retries = self._calculate_timeouts(etcd_nodes)
connect_timeout = max(1, per_node_timeout/2) connect_timeout = max(1.0, per_node_timeout / 2.0)
kwargs.update(timeout=Timeout(connect=connect_timeout, total=per_node_timeout), retries=per_node_retries) kwargs.update(timeout=Timeout(connect=connect_timeout, total=per_node_timeout), retries=per_node_retries)
return kwargs return kwargs
def set_machines_cache_ttl(self, cache_ttl): def set_machines_cache_ttl(self, cache_ttl: int) -> None:
self._machines_cache_ttl = cache_ttl self._machines_cache_ttl = cache_ttl
@abc.abstractmethod @abc.abstractmethod
def _prepare_get_members(self, etcd_nodes): def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
"""returns: request parameters""" """returns: request parameters"""
@abc.abstractmethod @abc.abstractmethod
def _get_members(self, base_uri, **kwargs): def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
"""returns: list of clientURLs""" """returns: list of clientURLs"""
@property @property
def machines_cache(self): def machines_cache(self) -> List[str]:
base_uri, cache = self._base_uri, self._machines_cache base_uri, cache = self._base_uri, self._machines_cache
return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri] return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri]
@@ -207,10 +216,14 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
return self._get_machines_list(self.machines_cache) return self._get_machines_list(self.machines_cache)
def set_read_timeout(self, timeout): def set_read_timeout(self, timeout: float) -> None:
self._read_timeout = timeout self._read_timeout = timeout
def _do_http_request(self, retry, machines_cache, request_executor, method, path, fields=None, **kwargs): def _do_http_request(self, retry: Optional[Retry], machines_cache: List[str],
request_executor: Callable[..., urllib3.response.HTTPResponse],
method: str, path: str, fields: Optional[Dict[str, Any]] = None,
**kwargs: Any) -> urllib3.response.HTTPResponse:
is_watch_request = isinstance(fields, dict) and fields.get('wait') == 'true'
if fields is not None: if fields is not None:
kwargs['fields'] = fields kwargs['fields'] = fields
some_request_failed = False some_request_failed = False
@@ -233,8 +246,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
# whether the key didn't received an update or there is a network problem. # whether the key didn't received an update or there is a network problem.
elif i + 1 < len(machines_cache): elif i + 1 < len(machines_cache):
self.set_base_uri(machines_cache[i + 1]) self.set_base_uri(machines_cache[i + 1])
if (isinstance(fields, dict) and fields.get("wait") == "true" and if is_watch_request and isinstance(e, (ReadTimeoutError, ProtocolError)):
isinstance(e, (ReadTimeoutError, ProtocolError))):
logger.debug("Watch timed out.") logger.debug("Watch timed out.")
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e) raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
logger.error("Request to server %s failed: %r", base_uri, e) logger.error("Request to server %s failed: %r", base_uri, e)
@@ -246,10 +258,12 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
raise etcd.EtcdConnectionFailed('No more machines in the cluster') raise etcd.EtcdConnectionFailed('No more machines in the cluster')
@abc.abstractmethod @abc.abstractmethod
def _prepare_request(self, kwargs, params=None, method=None): def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
"""returns: request_executor""" """returns: request_executor"""
def api_execute(self, path, method, params=None, timeout=None): def api_execute(self, path: str, method: str, params: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None) -> Any:
retry = params.pop('retry', None) if isinstance(params, dict) else None retry = params.pop('retry', None) if isinstance(params, dict) else None
# Update machines_cache if previous attempt of update has failed # Update machines_cache if previous attempt of update has failed
@@ -277,6 +291,8 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
etcd_nodes = len(machines_cache) etcd_nodes = len(machines_cache)
except Exception as e: except Exception as e:
logger.debug('Failed to update list of etcd nodes: %r', e) logger.debug('Failed to update list of etcd nodes: %r', e)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(retry, Retry) # etcd.EtcdConnectionFailed is raised only if retry is not None!
sleeptime = retry.sleeptime sleeptime = retry.sleeptime
remaining_time = retry.stoptime - sleeptime - time.time() remaining_time = retry.stoptime - sleeptime - time.time()
nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time) nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time)
@@ -287,22 +303,22 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
retry.sleep_func(sleeptime) retry.sleep_func(sleeptime)
retry.update_delay() retry.update_delay()
# We still have some time left. Partially reduce `machines_cache` and retry request # We still have some time left. Partially reduce `machines_cache` and retry request
kwargs.update(timeout=Timeout(connect=max(1, timeout/2), total=timeout), retries=retries) kwargs.update(timeout=Timeout(connect=max(1.0, timeout / 2.0), total=timeout), retries=retries)
machines_cache = machines_cache[:nodes] machines_cache = machines_cache[:nodes]
@staticmethod @staticmethod
def get_srv_record(host): def get_srv_record(host: str) -> List[Tuple[str, int]]:
try: try:
return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')] return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')]
except DNSException: except DNSException:
return [] return []
def _get_machines_cache_from_srv(self, srv, srv_suffix=None): def _get_machines_cache_from_srv(self, srv: str, srv_suffix: Optional[str] = None) -> List[str]:
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record. """Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
This record should contain list of host and peer ports which could be used to run This record should contain list of host and peer ports which could be used to run
'GET http://{host}:{port}/members' request (peer protocol)""" 'GET http://{host}:{port}/members' request (peer protocol)"""
ret = [] ret: List[str] = []
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']: for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r
protocol = 'https' if '-ssl' in r else 'http' protocol = 'https' if '-ssl' in r else 'http'
@@ -327,15 +343,15 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
logger.warning('Can not resolve SRV for %s', srv) logger.warning('Can not resolve SRV for %s', srv)
return list(set(ret)) return list(set(ret))
def _get_machines_cache_from_dns(self, host, port): def _get_machines_cache_from_dns(self, host: str, port: int) -> List[str]:
"""One host might be resolved into multiple ip addresses. We will make list out of it""" """One host might be resolved into multiple ip addresses. We will make list out of it"""
if self.protocol == 'http': if self.protocol == 'http':
ret = map(lambda res: uri(self.protocol, res[-1][:2]), self._dns_resolver.resolve(host, port)) ret = [uri(self.protocol, res[-1][:2]) for res in self._dns_resolver.resolve(host, port)]
if ret: if ret:
return list(set(ret)) return list(set(ret))
return [uri(self.protocol, (host, port))] return [uri(self.protocol, (host, port))]
def _get_machines_cache_from_config(self): def _get_machines_cache_from_config(self) -> List[str]:
if 'proxy' in self._config: if 'proxy' in self._config:
return [uri(self.protocol, (self._config['host'], self._config['port']))] return [uri(self.protocol, (self._config['host'], self._config['port']))]
@@ -351,13 +367,14 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
return machines_cache return machines_cache
@staticmethod @staticmethod
def _update_dns_cache(func, machines): def _update_dns_cache(func: Callable[[str, int], None], machines: List[str]) -> None:
for url in machines: for url in machines:
r = urlparse(url) r = urlparse(url)
port = r.port or (443 if r.scheme == 'https' else 80) if r.hostname:
func(r.hostname, port) port = r.port or (443 if r.scheme == 'https' else 80)
func(r.hostname, port)
def _load_machines_cache(self): def _load_machines_cache(self) -> bool:
"""This method should fill up `_machines_cache` from scratch. """This method should fill up `_machines_cache` from scratch.
It could happen only in two cases: It could happen only in two cases:
1. During class initialization 1. During class initialization
@@ -417,7 +434,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
self._machines_cache_updated = time.time() self._machines_cache_updated = time.time()
return ret return ret
def set_base_uri(self, value): def set_base_uri(self, value: str) -> None:
if self._base_uri != value: if self._base_uri != value:
logger.info('Selected new etcd server %s', value) logger.info('Selected new etcd server %s', value)
self._base_uri = value self._base_uri = value
@@ -427,22 +444,25 @@ class EtcdClient(AbstractEtcdClientWithFailover):
ERROR_CLS = EtcdError ERROR_CLS = EtcdError
def __del__(self): def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
if self.http is not None: super(EtcdClient, self).__init__({**config, 'version_prefix': None}, dns_resolver, cache_ttl)
try:
self.http.clear()
except (ReferenceError, TypeError, AttributeError):
pass
def _prepare_get_members(self, etcd_nodes): def __del__(self) -> None:
try:
self.http.clear()
except (ReferenceError, TypeError, AttributeError):
pass
def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
return self._prepare_common_parameters(etcd_nodes) return self._prepare_common_parameters(etcd_nodes)
def _get_members(self, base_uri, **kwargs): def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs) response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs)
data = self._handle_server_response(response).data.decode('utf-8') data = self._handle_server_response(response).data.decode('utf-8')
return [m.strip() for m in data.split(',') if m.strip()] return [m.strip() for m in data.split(',') if m.strip()]
def _prepare_request(self, kwargs, params=None, method=None): def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
kwargs['fields'] = params kwargs['fields'] = params
if method in (self._MPOST, self._MPUT): if method in (self._MPOST, self._MPUT):
kwargs['encode_multipart'] = False kwargs['encode_multipart'] = False
@@ -451,25 +471,32 @@ class EtcdClient(AbstractEtcdClientWithFailover):
class AbstractEtcd(AbstractDCS): class AbstractEtcd(AbstractDCS):
def __init__(self, config, client_cls, retry_errors_cls): def __init__(self, config: Dict[str, Any], mpp: AbstractMPP, client_cls: Type[AbstractEtcdClientWithFailover],
super(AbstractEtcd, self).__init__(config) retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
super(AbstractEtcd, self).__init__(config, mpp)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1, self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=retry_errors_cls) retry_exceptions=retry_errors_cls)
self._ttl = int(config.get('ttl') or 30) self._ttl = int(config.get('ttl') or 30)
self._client = self.get_etcd_client(config, client_cls) self._abstract_client = self.get_etcd_client(config, client_cls)
self.__do_not_watch = False self.__do_not_watch = False
self._has_failed = False self._has_failed = False
def reload_config(self, config): @property
@abc.abstractmethod
def _client(self) -> AbstractEtcdClientWithFailover:
"""return correct type of etcd client"""
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
super(AbstractEtcd, self).reload_config(config) super(AbstractEtcd, self).reload_config(config)
self._client.reload_config(config.get(self.__class__.__name__.lower(), {})) self._client.reload_config(config.get(self.__class__.__name__.lower(), {}))
def retry(self, *args, **kwargs): def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
retry = self._retry.copy() retry = self._retry.copy()
kwargs['retry'] = retry kwargs['retry'] = retry
return retry(*args, **kwargs) return retry(method, *args, **kwargs)
def _handle_exception(self, e, name='', do_sleep=False, raise_ex=None): def _handle_exception(self, e: Exception, name: str = '', do_sleep: bool = False,
raise_ex: Optional[Exception] = None) -> None:
if not self._has_failed: if not self._has_failed:
logger.exception(name) logger.exception(name)
else: else:
@@ -480,7 +507,18 @@ class AbstractEtcd(AbstractDCS):
if isinstance(raise_ex, Exception): if isinstance(raise_ex, Exception):
raise raise_ex raise raise_ex
def _run_and_handle_exceptions(self, method, *args, **kwargs): def handle_etcd_exceptions(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
retval = func(self, *args, **kwargs)
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
def _run_and_handle_exceptions(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
retry = kwargs.pop('retry', self.retry) retry = kwargs.pop('retry', self.retry)
try: try:
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs) return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
@@ -492,19 +530,20 @@ class AbstractEtcd(AbstractDCS):
except Exception as e: except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error')) self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
@staticmethod def set_socket_options(self, sock: socket.socket,
def set_socket_options(sock, socket_options): socket_options: Optional[Collection[Tuple[int, int, int]]]) -> None:
if socket_options: if socket_options:
for opt in socket_options: for opt in socket_options:
sock.setsockopt(*opt) sock.setsockopt(*opt)
def get_etcd_client(self, config, client_cls): def get_etcd_client(self, config: Dict[str, Any],
client_cls: Type[AbstractEtcdClientWithFailover]) -> AbstractEtcdClientWithFailover:
config = deepcopy(config) config = deepcopy(config)
if 'proxy' in config: if 'proxy' in config:
config['use_proxies'] = True config['use_proxies'] = True
config['url'] = config['proxy'] config['url'] = config['proxy']
if 'url' in config: if 'url' in config and isinstance(config['url'], str):
r = urlparse(config['url']) r = urlparse(config['url'])
config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379, config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379,
'username': r.username, 'password': r.password}) 'username': r.username, 'password': r.password})
@@ -516,10 +555,11 @@ class AbstractEtcd(AbstractDCS):
if isinstance(hosts, str): if isinstance(hosts, str):
hosts = hosts.split(',') hosts = hosts.split(',')
config['hosts'] = [] config_hosts: List[str] = []
for value in hosts: for value in hosts:
if isinstance(value, str): if isinstance(value, str):
config['hosts'].append(uri(protocol, split_host_port(value.strip(), default_port))) config_hosts.append(uri(protocol, split_host_port(value.strip(), default_port)))
config['hosts'] = config_hosts
elif 'host' in config: elif 'host' in config:
host, port = split_host_port(config['host'], 2379) host, port = split_host_port(config['host'], 2379)
config['host'] = host config['host'] = host
@@ -538,8 +578,10 @@ class AbstractEtcd(AbstractDCS):
dns_resolver = DnsCachingResolver() dns_resolver = DnsCachingResolver()
def create_connection_patched(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, def create_connection_patched(
source_address=None, socket_options=None): address: Tuple[str, int], timeout: Any = object(),
source_address: Optional[Any] = None, socket_options: Optional[Collection[Tuple[int, int, int]]] = None
) -> socket.socket:
host, port = address host, port = address
if host.startswith('['): if host.startswith('['):
host = host.strip('[]') host = host.strip('[]')
@@ -549,7 +591,7 @@ class AbstractEtcd(AbstractDCS):
try: try:
sock = socket.socket(af, socktype, proto) sock = socket.socket(af, socktype, proto)
self.set_socket_options(sock, socket_options) self.set_socket_options(sock, socket_options)
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: if timeout is None or isinstance(timeout, (float, int)):
sock.settimeout(timeout) sock.settimeout(timeout)
if source_address: if source_address:
sock.bind(source_address) sock.bind(source_address)
@@ -580,51 +622,49 @@ class AbstractEtcd(AbstractDCS):
time.sleep(5) time.sleep(5)
return client return client
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
ttl = int(ttl) ttl = int(ttl)
ret = self._ttl != ttl ret = self._ttl != ttl
self._ttl = ttl self._ttl = ttl
self._client.set_machines_cache_ttl(ttl*10) self._client.set_machines_cache_ttl(ttl * 10)
return ret return ret
@property @property
def ttl(self): def ttl(self) -> int:
return self._ttl return self._ttl
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self._retry.deadline = retry_timeout self._retry.deadline = retry_timeout
self._client.set_read_timeout(retry_timeout) self._client.set_read_timeout(retry_timeout)
def catch_etcd_errors(func): def catch_etcd_errors(func: Callable[..., Any]) -> Any:
def wrapper(self, *args, **kwargs): def wrapper(self: AbstractEtcd, *args: Any, **kwargs: Any) -> Any:
try: return self.handle_etcd_exceptions(func, *args, **kwargs)
retval = func(self, *args, **kwargs) is not None
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
return wrapper return wrapper
class Etcd(AbstractEtcd): class Etcd(AbstractEtcd):
def __init__(self, config): def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal)) super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self.__do_not_watch = False self.__do_not_watch = False
def set_ttl(self, ttl): @property
def _client(self) -> EtcdClient:
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self._abstract_client, EtcdClient)
return self._abstract_client
def set_ttl(self, ttl: int) -> Optional[bool]:
self.__do_not_watch = super(Etcd, self).set_ttl(ttl) self.__do_not_watch = super(Etcd, self).set_ttl(ttl)
return None
@staticmethod @staticmethod
def member(node): def member(node: etcd.EtcdResult) -> Member:
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value) return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
def _cluster_from_nodes(self, etcd_index, nodes): def _cluster_from_nodes(self, etcd_index: int, nodes: Dict[str, etcd.EtcdResult]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize.value initialize = initialize and initialize.value
@@ -638,23 +678,8 @@ class Etcd(AbstractEtcd):
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value) history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
# get last know leader lsn and slots # get last know leader lsn and slots
status = nodes.get(self._STATUS) status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
if status: status = Status.from_node(status and status.value)
try:
status = json.loads(status.value)
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn.value
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -664,8 +689,8 @@ class Etcd(AbstractEtcd):
if leader: if leader:
member = Member(-1, leader.value, None, {}) member = Member(-1, leader.value, None, {})
member = ([m for m in members if m.name == leader.value] or [member])[0] member = ([m for m in members if m.name == leader.value] or [member])[0]
index = etcd_index if etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1 version = etcd_index if etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1
leader = Leader(index, leader.ttl, member) leader = Leader(version, leader.ttl, member)
# failover key # failover key
failover = nodes.get(self._FAILOVER) failover = nodes.get(self._FAILOVER)
@@ -683,43 +708,64 @@ class Etcd(AbstractEtcd):
except Exception: except Exception:
failsafe = None 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): def _postgresql_cluster_loader(self, path: str) -> Cluster:
result = self.retry(self._client.read, path, recursive=True) """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} nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
return self._cluster_from_nodes(result.etcd_index, nodes) return self._cluster_from_nodes(result.etcd_index, nodes)
def _citus_cluster_loader(self, path): def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters = defaultdict(dict) """Load and build all PostgreSQL clusters from a single MPP cluster.
result = self.retry(self._client.read, path, recursive=True)
: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)
for node in result.leaves: for node in result.leaves:
key = node.key[len(result.key):].lstrip('/').split('/', 1) key = node.key[len(result.key):].lstrip('/').split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]): if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = node clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
cluster = None cluster = None
try: try:
cluster = loader(path) cluster = loader(path)
except etcd.EtcdKeyNotFound:
cluster = Cluster.empty()
except Exception as e: except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly')) self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
self._has_failed = False self._has_failed = False
if TYPE_CHECKING: # pragma: no cover
assert cluster is not None
return cluster return cluster
@catch_etcd_errors @catch_etcd_errors
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
data = json.dumps(data, separators=(',', ':')) value = json.dumps(data, separators=(',', ':'))
return self._client.set(self.member_path, data, self._ttl) return bool(self._client.set(self.member_path, value, self._ttl))
@catch_etcd_errors @catch_etcd_errors
def take_leader(self): def take_leader(self) -> bool:
return self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl) return self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl)
def _do_attempt_to_acquire_leader(self): def _do_attempt_to_acquire_leader(self) -> bool:
try: try:
return bool(self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl, prevExist=False)) return bool(self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl, prevExist=False))
except etcd.EtcdAlreadyExist: except etcd.EtcdAlreadyExist:
@@ -727,26 +773,26 @@ class Etcd(AbstractEtcd):
return False return False
@catch_return_false_exception @catch_return_false_exception
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
return self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry=None) return self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry=None)
@catch_etcd_errors @catch_etcd_errors
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.write(self.failover_path, value, prevIndex=index or 0) return bool(self._client.write(self.failover_path, value, prevIndex=version or 0))
@catch_etcd_errors @catch_etcd_errors
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._client.write(self.config_path, value, prevIndex=index or 0) return bool(self._client.write(self.config_path, value, prevIndex=version or 0))
@catch_etcd_errors @catch_etcd_errors
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._client.set(self.leader_optime_path, last_lsn) return bool(self._client.set(self.leader_optime_path, last_lsn))
@catch_etcd_errors @catch_etcd_errors
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._client.set(self.status_path, value) return bool(self._client.set(self.status_path, value))
def _do_update_leader(self): def _do_update_leader(self) -> bool:
try: try:
return self.retry(self._client.write, self.leader_path, self._name, return self.retry(self._client.write, self.leader_path, self._name,
prevValue=self._name, ttl=self._ttl) is not None prevValue=self._name, ttl=self._ttl) is not None
@@ -754,52 +800,52 @@ class Etcd(AbstractEtcd):
return self._do_attempt_to_acquire_leader() return self._do_attempt_to_acquire_leader()
@catch_etcd_errors @catch_etcd_errors
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._client.set(self.failsafe_path, value) return bool(self._client.set(self.failsafe_path, value))
@catch_return_false_exception @catch_return_false_exception
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
return self._run_and_handle_exceptions(self._do_update_leader, retry=None) return bool(self._run_and_handle_exceptions(self._do_update_leader, retry=None))
@catch_etcd_errors @catch_etcd_errors
def initialize(self, create_new=True, sysid=""): def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
return self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new)) return bool(self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new)))
@catch_etcd_errors @catch_etcd_errors
def _delete_leader(self): def _delete_leader(self, leader: Leader) -> bool:
return self._client.delete(self.leader_path, prevValue=self._name) return bool(self._client.delete(self.leader_path, prevValue=self._name))
@catch_etcd_errors @catch_etcd_errors
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self.retry(self._client.delete, self.initialize_path) return bool(self.retry(self._client.delete, self.initialize_path))
@catch_etcd_errors @catch_etcd_errors
def delete_cluster(self): def delete_cluster(self) -> bool:
return self.retry(self._client.delete, self.client_path(''), recursive=True) return bool(self.retry(self._client.delete, self.client_path(''), recursive=True))
@catch_etcd_errors @catch_etcd_errors
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._client.write(self.history_path, value) return bool(self._client.write(self.history_path, value))
@catch_etcd_errors @catch_etcd_errors
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self.retry(self._client.write, self.sync_path, value, prevIndex=index or 0) return self.retry(self._client.write, self.sync_path, value, prevIndex=version or 0).modifiedIndex
@catch_etcd_errors @catch_etcd_errors
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self.retry(self._client.delete, self.sync_path, prevIndex=index or 0) return bool(self.retry(self._client.delete, self.sync_path, prevIndex=version or 0))
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
if self.__do_not_watch: if self.__do_not_watch:
self.__do_not_watch = False self.__do_not_watch = False
return True return True
if leader_index: if leader_version:
end_time = time.time() + timeout end_time = time.time() + timeout
while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try: try:
result = self._client.watch(self.leader_path, index=leader_index, timeout=timeout + 0.5) result = self._client.watch(self.leader_path, index=leader_version, timeout=timeout + 0.5)
self._has_failed = False self._has_failed = False
if result.action == 'compareAndSwap': if result.action == 'compareAndSwap':
time.sleep(0.01) time.sleep(0.01)
+331 -234
View File
File diff suppressed because it is too large Load Diff
+20 -12
View File
@@ -3,9 +3,13 @@ import logging
import random import random
import time import time
from patroni.dcs.zookeeper import ZooKeeper from typing import Any, Callable, Dict, List, Union
from patroni.request import get as requests_get
from patroni.utils import uri from . import Cluster
from .zookeeper import ZooKeeper
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,11 +18,12 @@ class ExhibitorEnsembleProvider(object):
TIMEOUT = 3.1 TIMEOUT = 3.1
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300): def __init__(self, hosts: List[str], port: int,
uri_path: str = '/exhibitor/v1/cluster/list', poll_interval: int = 300) -> None:
self._exhibitor_port = port self._exhibitor_port = port
self._uri_path = uri_path self._uri_path = uri_path
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._exhibitors = hosts self._exhibitors: List[str] = hosts
self._boot_exhibitors = hosts self._boot_exhibitors = hosts
self._zookeeper_hosts = '' self._zookeeper_hosts = ''
self._next_poll = None self._next_poll = None
@@ -26,7 +31,7 @@ class ExhibitorEnsembleProvider(object):
logger.info('waiting on exhibitor') logger.info('waiting on exhibitor')
time.sleep(5) time.sleep(5)
def poll(self): def poll(self) -> bool:
if self._next_poll and self._next_poll > time.time(): if self._next_poll and self._next_poll > time.time():
return False return False
@@ -36,7 +41,8 @@ class ExhibitorEnsembleProvider(object):
if isinstance(json, dict) and 'servers' in json and 'port' in json: if isinstance(json, dict) and 'servers' in json and 'port' in json:
self._next_poll = time.time() + self._poll_interval self._next_poll = time.time() + self._poll_interval
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])]) servers: List[str] = json['servers']
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(servers)])
if self._zookeeper_hosts != zookeeper_hosts: if self._zookeeper_hosts != zookeeper_hosts:
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts) logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
self._zookeeper_hosts = zookeeper_hosts self._zookeeper_hosts = zookeeper_hosts
@@ -44,7 +50,7 @@ class ExhibitorEnsembleProvider(object):
return True return True
return False return False
def _query_exhibitors(self, exhibitors): def _query_exhibitors(self, exhibitors: List[str]) -> Union[Dict[str, Any], Any]:
random.shuffle(exhibitors) random.shuffle(exhibitors)
for host in exhibitors: for host in exhibitors:
try: try:
@@ -55,18 +61,20 @@ class ExhibitorEnsembleProvider(object):
return None return None
@property @property
def zookeeper_hosts(self): def zookeeper_hosts(self) -> str:
return self._zookeeper_hosts return self._zookeeper_hosts
class Exhibitor(ZooKeeper): class Exhibitor(ZooKeeper):
def __init__(self, config): def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
interval = config.get('poll_interval', 300) interval = config.get('poll_interval', 300)
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval) self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}) super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}, mpp)
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
if self._ensemble_provider.poll(): if self._ensemble_provider.poll():
self._client.set_hosts(self._ensemble_provider.zookeeper_hosts) self._client.set_hosts(self._ensemble_provider.zookeeper_hosts)
return super(Exhibitor, self)._load_cluster(path, loader) return super(Exhibitor, self)._load_cluster(path, loader)
+392 -265
View File
File diff suppressed because it is too large Load Diff
+123 -110
View File
@@ -10,10 +10,14 @@ from pysyncobj.dns_resolver import globalDnsResolver
from pysyncobj.node import TCPNode from pysyncobj.node import TCPNode
from pysyncobj.transport import TCPTransport, CONNECTION_STATE from pysyncobj.transport import TCPTransport, CONNECTION_STATE
from pysyncobj.utility import TcpUtility from pysyncobj.utility import TcpUtility
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..exceptions import DCSError from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import validate_directory from ..utils import validate_directory
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,11 +28,12 @@ class RaftError(DCSError):
class _TCPTransport(TCPTransport): class _TCPTransport(TCPTransport):
def __init__(self, syncObj, selfNode, otherNodes): def __init__(self, syncObj: 'DynMemberSyncObj', selfNode: Optional[TCPNode],
otherNodes: Collection[TCPNode]) -> None:
super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes) super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes)
self.setOnUtilityMessageCallback('members', syncObj.getMembers) self.setOnUtilityMessageCallback('members', syncObj.getMembers)
def _connectIfNecessarySingle(self, node): def _connectIfNecessarySingle(self, node: TCPNode) -> bool:
try: try:
return super(_TCPTransport, self)._connectIfNecessarySingle(node) return super(_TCPTransport, self)._connectIfNecessarySingle(node)
except Exception as e: except Exception as e:
@@ -36,7 +41,7 @@ class _TCPTransport(TCPTransport):
return False return False
def resolve_host(self): def resolve_host(self: TCPNode) -> Optional[str]:
return globalDnsResolver().resolve(self.host) return globalDnsResolver().resolve(self.host)
@@ -45,17 +50,19 @@ setattr(TCPNode, 'ip', property(resolve_host))
class SyncObjUtility(object): class SyncObjUtility(object):
def __init__(self, otherNodes, conf, retry_timeout=10): def __init__(self, otherNodes: Collection[Union[str, TCPNode]], conf: SyncObjConf, retry_timeout: int = 10) -> None:
self._nodes = otherNodes self._nodes = otherNodes
self._utility = TcpUtility(conf.password, retry_timeout/max(1, len(otherNodes))) self._utility = TcpUtility(conf.password, retry_timeout / max(1, len(otherNodes)))
self.__node = next(iter(otherNodes), None)
def executeCommand(self, command): def executeCommand(self, command: List[Any]) -> Any:
try: try:
return self._utility.executeCommand(self.__node, command) if self.__node:
return self._utility.executeCommand(self.__node, command)
except Exception: except Exception:
return None return None
def getMembers(self): def getMembers(self) -> Optional[List[str]]:
for self.__node in self._nodes: for self.__node in self._nodes:
response = self.executeCommand(['members']) response = self.executeCommand(['members'])
if response: if response:
@@ -64,7 +71,8 @@ class SyncObjUtility(object):
class DynMemberSyncObj(SyncObj): class DynMemberSyncObj(SyncObj):
def __init__(self, selfAddress, partnerAddrs, conf, retry_timeout=10): def __init__(self, selfAddress: Optional[str], partnerAddrs: Collection[str],
conf: SyncObjConf, retry_timeout: int = 10) -> None:
self.__early_apply_local_log = selfAddress is not None self.__early_apply_local_log = selfAddress is not None
self.applied_local_log = False self.applied_local_log = False
@@ -81,12 +89,12 @@ class DynMemberSyncObj(SyncObj):
thread.daemon = True thread.daemon = True
thread.start() thread.start()
def getMembers(self, args, callback): def getMembers(self, args: Any, callback: Callable[[Any, Any], Any]) -> None:
callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED
if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes] + if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes]
[{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None) + [{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
def _onTick(self, timeToWait=0.0): def _onTick(self, timeToWait: float = 0.0):
super(DynMemberSyncObj, self)._onTick(timeToWait) super(DynMemberSyncObj, self)._onTick(timeToWait)
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes. # The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
@@ -98,15 +106,16 @@ class DynMemberSyncObj(SyncObj):
class KVStoreTTL(DynMemberSyncObj): class KVStoreTTL(DynMemberSyncObj):
def __init__(self, on_ready, on_set, on_delete, **config): def __init__(self, on_ready: Optional[Callable[..., Any]], on_set: Optional[Callable[[str, Dict[str, Any]], None]],
on_delete: Optional[Callable[[str], None]], **config: Any) -> None:
self.__thread = None self.__thread = None
self.__on_set = on_set self.__on_set = on_set
self.__on_delete = on_delete self.__on_delete = on_delete
self.__limb = {} self.__limb: Dict[str, Dict[str, Any]] = {}
self.set_retry_timeout(int(config.get('retry_timeout') or 10)) self.set_retry_timeout(int(config.get('retry_timeout') or 10))
self_addr = config.get('self_addr') self_addr = config.get('self_addr')
partner_addrs = set(config.get('partner_addrs', [])) partner_addrs: Set[str] = set(config.get('partner_addrs', []))
if config.get('patronictl'): if config.get('patronictl'):
if self_addr: if self_addr:
partner_addrs.add(self_addr) partner_addrs.add(self_addr)
@@ -128,22 +137,22 @@ class KVStoreTTL(DynMemberSyncObj):
onReady=on_ready, dynamicMembershipChange=True) onReady=on_ready, dynamicMembershipChange=True)
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout) super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout)
self.__data = {} self.__data: Dict[str, Dict[str, Any]] = {}
@staticmethod @staticmethod
def __check_requirements(old_value, **kwargs): def __check_requirements(old_value: Dict[str, Any], **kwargs: Any) -> bool:
return ('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value)) and \ return bool(('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value))
('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue']) and \ and ('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue'])
(not kwargs.get('prevIndex') or old_value and old_value['index'] == kwargs['prevIndex']) and (kwargs.get('prevIndex') is None or old_value and old_value['index'] == kwargs['prevIndex']))
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self.__retry_timeout = retry_timeout self.__retry_timeout = retry_timeout
def retry(self, func, *args, **kwargs): def retry(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
event = threading.Event() event = threading.Event()
ret = {'result': None, 'error': -1} ret = {'result': None, 'error': -1}
def callback(result, error): def callback(result: Any, error: Any) -> None:
ret.update(result=result, error=error) ret.update(result=result, error=error)
event.set() event.set()
@@ -167,7 +176,7 @@ class KVStoreTTL(DynMemberSyncObj):
return False return False
@replicated @replicated
def _set(self, key, value, **kwargs): def _set(self, key: str, value: Dict[str, Any], **kwargs: Any) -> Union[bool, Dict[str, Any]]:
old_value = self.__data.get(key, {}) old_value = self.__data.get(key, {})
if not self.__check_requirements(old_value, **kwargs): if not self.__check_requirements(old_value, **kwargs):
return False return False
@@ -179,31 +188,32 @@ class KVStoreTTL(DynMemberSyncObj):
self.__data[key] = value self.__data[key] = value
if self.__on_set: if self.__on_set:
self.__on_set(key, value) self.__on_set(key, value)
return True return value
def set(self, key, value, ttl=None, handle_raft_error=True, **kwargs): def set(self, key: str, value: str, ttl: Optional[int] = None,
handle_raft_error: bool = True, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
old_value = self.__data.get(key, {}) old_value = self.__data.get(key, {})
if not self.__check_requirements(old_value, **kwargs): if not self.__check_requirements(old_value, **kwargs):
return False return False
value = {'value': value, 'updated': time.time()} data: Dict[str, Any] = {'value': value, 'updated': time.time()}
value['created'] = old_value.get('created', value['updated']) data['created'] = old_value.get('created', data['updated'])
if ttl: if ttl:
value['expire'] = value['updated'] + ttl data['expire'] = data['updated'] + ttl
try: try:
return self.retry(self._set, key, value, **kwargs) return self.retry(self._set, key, data, **kwargs)
except RaftError: except RaftError:
if not handle_raft_error: if not handle_raft_error:
raise raise
return False return False
def __pop(self, key): def __pop(self, key: str) -> None:
self.__data.pop(key) self.__data.pop(key)
if self.__on_delete: if self.__on_delete:
self.__on_delete(key) self.__on_delete(key)
@replicated @replicated
def _delete(self, key, recursive=False, **kwargs): def _delete(self, key: str, recursive: bool = False, **kwargs: Any) -> bool:
if recursive: if recursive:
for k in list(self.__data.keys()): for k in list(self.__data.keys()):
if k.startswith(key): if k.startswith(key):
@@ -214,7 +224,7 @@ class KVStoreTTL(DynMemberSyncObj):
self.__pop(key) self.__pop(key)
return True return True
def delete(self, key, recursive=False, **kwargs): def delete(self, key: str, recursive: bool = False, **kwargs: Any) -> bool:
if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs): if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs):
return False return False
try: try:
@@ -223,32 +233,32 @@ class KVStoreTTL(DynMemberSyncObj):
return False return False
@staticmethod @staticmethod
def __values_match(old, new): def __values_match(old: Dict[str, Any], new: Dict[str, Any]) -> bool:
return all(old.get(n) == new.get(n) for n in ('created', 'updated', 'expire', 'value')) return all(old.get(n) == new.get(n) for n in ('created', 'updated', 'expire', 'value'))
@replicated @replicated
def _expire(self, key, value, callback=None): def _expire(self, key: str, value: Dict[str, Any], callback: Optional[Callable[..., Any]] = None) -> None:
current = self.__data.get(key) current = self.__data.get(key)
if current and self.__values_match(current, value): if current and self.__values_match(current, value):
self.__pop(key) self.__pop(key)
def __expire_keys(self): def __expire_keys(self) -> None:
for key, value in self.__data.items(): for key, value in self.__data.items():
if value and 'expire' in value and value['expire'] <= time.time() and \ if value and 'expire' in value and value['expire'] <= time.time() and \
not (key in self.__limb and self.__values_match(self.__limb[key], value)): not (key in self.__limb and self.__values_match(self.__limb[key], value)):
self.__limb[key] = value self.__limb[key] = value
def callback(*args): def callback(*args: Any) -> None:
if key in self.__limb and self.__values_match(self.__limb[key], value): if key in self.__limb and self.__values_match(self.__limb[key], value):
self.__limb.pop(key) self.__limb.pop(key)
self._expire(key, value, callback=callback) self._expire(key, value, callback=callback)
def get(self, key, recursive=False): def get(self, key: str, recursive: bool = False) -> Union[None, Dict[str, Any], Dict[str, Dict[str, Any]]]:
if not recursive: if not recursive:
return self.__data.get(key) return self.__data.get(key)
return {k: v for k, v in self.__data.items() if k.startswith(key)} return {k: v for k, v in self.__data.items() if k.startswith(key)}
def _onTick(self, timeToWait=0.0): def _onTick(self, timeToWait: float = 0.0) -> None:
super(KVStoreTTL, self)._onTick(timeToWait) super(KVStoreTTL, self)._onTick(timeToWait)
if self._isLeader(): if self._isLeader():
@@ -256,17 +266,17 @@ class KVStoreTTL(DynMemberSyncObj):
else: else:
self.__limb.clear() self.__limb.clear()
def _autoTickThread(self): def _autoTickThread(self) -> None:
self.__destroying = False self.__destroying = False
while not self.__destroying: while not self.__destroying:
self.doTick(self.conf.autoTickPeriod) self.doTick(self.conf.autoTickPeriod)
def startAutoTick(self): def startAutoTick(self) -> None:
self.__thread = threading.Thread(target=self._autoTickThread) self.__thread = threading.Thread(target=self._autoTickThread)
self.__thread.daemon = True self.__thread.daemon = True
self.__thread.start() self.__thread.start()
def destroy(self): def destroy(self) -> None:
if self.__thread: if self.__thread:
self.__destroying = True self.__destroying = True
self.__thread.join() self.__thread.join()
@@ -275,8 +285,8 @@ class KVStoreTTL(DynMemberSyncObj):
class Raft(AbstractDCS): class Raft(AbstractDCS):
def __init__(self, config): def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Raft, self).__init__(config) super(Raft, self).__init__(config, mpp)
self._ttl = int(config.get('ttl') or 30) self._ttl = int(config.get('ttl') or 30)
ready_event = threading.Event() ready_event = threading.Event()
@@ -290,7 +300,7 @@ class Raft(AbstractDCS):
else: else:
logger.info('waiting on raft') logger.info('waiting on raft')
def _on_set(self, key, value): def _on_set(self, key: str, value: Dict[str, Any]) -> None:
leader = (self._sync_obj.get(self.leader_path) or {}).get('value') leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
if key == value['created'] == value['updated'] and \ if key == value['created'] == value['updated'] and \
(key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \ (key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \
@@ -298,29 +308,29 @@ class Raft(AbstractDCS):
key in (self.config_path, self.sync_path): key in (self.config_path, self.sync_path):
self.event.set() self.event.set()
def _on_delete(self, key): def _on_delete(self, key: str) -> None:
if key == self.leader_path: if key == self.leader_path:
self.event.set() self.event.set()
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
self._ttl = ttl self._ttl = ttl
@property @property
def ttl(self): def ttl(self) -> int:
return self._ttl return self._ttl
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
self._sync_obj.set_retry_timeout(retry_timeout) self._sync_obj.set_retry_timeout(retry_timeout)
def reload_config(self, config): def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
super(Raft, self).reload_config(config) super(Raft, self).reload_config(config)
globalDnsResolver().setTimeouts(self.ttl, self.loop_wait) globalDnsResolver().setTimeouts(self.ttl, self.loop_wait)
@staticmethod @staticmethod
def member(key, value): def member(key: str, value: Dict[str, Any]) -> Member:
return Member.from_node(value['index'], os.path.basename(key), None, value['value']) return Member.from_node(value['index'], os.path.basename(key), None, value['value'])
def _cluster_from_nodes(self, nodes): def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
# get initialize flag # get initialize flag
initialize = nodes.get(self._INITIALIZE) initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['value'] initialize = initialize and initialize['value']
@@ -334,23 +344,8 @@ class Raft(AbstractDCS):
history = history and TimelineHistory.from_node(history['index'], history['value']) history = history and TimelineHistory.from_node(history['index'], history['value'])
# get last know leader lsn and slots # get last know leader lsn and slots
status = nodes.get(self._STATUS) status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
if status: status = Status.from_node(status and status['value'])
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -378,82 +373,100 @@ class Raft(AbstractDCS):
except Exception: except Exception:
failsafe = None 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): 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) response = self._sync_obj.get(path, recursive=True)
if not response: if not response:
return Cluster.empty() return Cluster.empty()
nodes = {key[len(path):]: value for key, value in response.items()} nodes = {key[len(path):]: value for key, value in response.items()}
return self._cluster_from_nodes(nodes) return self._cluster_from_nodes(nodes)
def _citus_cluster_loader(self, path): def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters = defaultdict(dict) """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) response = self._sync_obj.get(path, recursive=True)
for key, value in response.items(): for key, value in (response or {}).items():
key = key[len(path):].split('/', 1) key = key[len(path):].split('/', 1)
if len(key) == 2 and citus_group_re.match(key[0]): if len(key) == 2 and self._mpp.group_re.match(key[0]):
clusters[int(key[0])][key[1]] = value clusters[int(key[0])][key[1]] = value
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()} return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
def _load_cluster(self, path, loader): def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
return loader(path) return loader(path)
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1) return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1) is not False
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._sync_obj.set(self.status_path, value, timeout=1) return self._sync_obj.set(self.status_path, value, timeout=1) is not False
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._sync_obj.set(self.failsafe_path, value, timeout=1) return self._sync_obj.set(self.failsafe_path, value, timeout=1) is not False
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
handle_raft_error=False, prevValue=self._name) handle_raft_error=False, prevValue=self._name) is not False
if not ret and self._sync_obj.get(self.leader_path) is None: if not ret and self._sync_obj.get(self.leader_path) is None:
ret = self.attempt_to_acquire_leader() ret = self.attempt_to_acquire_leader()
return ret return ret
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, handle_raft_error=False, prevExist=False) return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
handle_raft_error=False, prevExist=False) is not False
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._sync_obj.set(self.failover_path, value, prevIndex=index) return self._sync_obj.set(self.failover_path, value, prevIndex=version) is not False
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._sync_obj.set(self.config_path, value, prevIndex=index) return self._sync_obj.set(self.config_path, value, prevIndex=version) is not False
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
data = json.dumps(data, separators=(',', ':')) value = json.dumps(data, separators=(',', ':'))
return self._sync_obj.set(self.member_path, data, self._ttl, timeout=2) return self._sync_obj.set(self.member_path, value, self._ttl, timeout=2) is not False
def take_leader(self): def take_leader(self) -> bool:
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl) return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl) is not False
def initialize(self, create_new=True, sysid=''): def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new)) return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new)) is not False
def _delete_leader(self): def _delete_leader(self, leader: Leader) -> bool:
return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1) return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1)
def cancel_initialization(self): def cancel_initialization(self) -> bool:
return self._sync_obj.delete(self.initialize_path) return self._sync_obj.delete(self.initialize_path)
def delete_cluster(self): def delete_cluster(self) -> bool:
return self._sync_obj.delete(self.client_path(''), recursive=True) return self._sync_obj.delete(self.client_path(''), recursive=True)
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._sync_obj.set(self.history_path, value) return self._sync_obj.set(self.history_path, value) is not False
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self._sync_obj.set(self.sync_path, value, prevIndex=index) ret = self._sync_obj.set(self.sync_path, value, prevIndex=version)
if isinstance(ret, dict):
return ret['index']
return ret
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self._sync_obj.delete(self.sync_path, prevIndex=index) return self._sync_obj.delete(self.sync_path, prevIndex=version)
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
try: try:
return super(Raft, self).watch(leader_index, timeout) return super(Raft, self).watch(leader_version, timeout)
finally: finally:
self.event.clear() self.event.clear()
+161 -199
View File
@@ -1,18 +1,23 @@
import json import json
import logging import logging
import select import select
import socket
import time import time
from kazoo.client import KazooClient, KazooState, KazooRetry from kazoo.client import KazooClient, KazooState, KazooRetry
from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError
from kazoo.handlers.threading import SequentialThreadingHandler from kazoo.handlers.threading import AsyncResult, SequentialThreadingHandler
from kazoo.protocol.states import KeeperState from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
from kazoo.retry import RetryFailedError from kazoo.retry import RetryFailedError
from kazoo.security import make_acl from kazoo.security import ACL, make_acl
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..exceptions import DCSError from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare from ..utils import deep_compare
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,14 +28,14 @@ class ZooKeeperError(DCSError):
class PatroniSequentialThreadingHandler(SequentialThreadingHandler): class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
def __init__(self, connect_timeout): def __init__(self, connect_timeout: Union[int, float]) -> None:
super(PatroniSequentialThreadingHandler, self).__init__() super(PatroniSequentialThreadingHandler, self).__init__()
self.set_connect_timeout(connect_timeout) self.set_connect_timeout(connect_timeout)
def set_connect_timeout(self, connect_timeout): def set_connect_timeout(self, connect_timeout: Union[int, float]) -> None:
self._connect_timeout = max(1.0, connect_timeout/2.0) # try to connect to zookeeper node during loop_wait/2 self._connect_timeout = max(1.0, connect_timeout / 2.0) # try to connect to zookeeper node during loop_wait/2
def create_connection(self, *args, **kwargs): def create_connection(self, *args: Any, **kwargs: Any) -> socket.socket:
"""This method is trying to establish connection with one of the zookeeper nodes. """This method is trying to establish connection with one of the zookeeper nodes.
Somehow strategy "fail earlier and retry more often" works way better comparing to Somehow strategy "fail earlier and retry more often" works way better comparing to
the original strategy "try to connect with specified timeout". the original strategy "try to connect with specified timeout".
@@ -41,16 +46,16 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
:param args: always contains `tuple(host, port)` as the first element and could contain :param args: always contains `tuple(host, port)` as the first element and could contain
`connect_timeout` (negotiated session timeout) as the second element.""" `connect_timeout` (negotiated session timeout) as the second element."""
args = list(args) args_list: List[Any] = list(args)
if len(args) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method if len(args_list) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method
kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout*10)/10.0) kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout * 10) / 10.0)
elif len(args) == 1: elif len(args_list) == 1:
args.append(self._connect_timeout) args_list.append(self._connect_timeout)
else: else:
args[1] = max(self._connect_timeout, args[1]/10.0) args_list[1] = max(self._connect_timeout, args_list[1] / 10.0)
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs) return super(PatroniSequentialThreadingHandler, self).create_connection(*args_list, **kwargs)
def select(self, *args, **kwargs): def select(self, *args: Any, **kwargs: Any) -> Any:
""" """
Python 3.XY may raise following exceptions if select/poll are called with an invalid socket: Python 3.XY may raise following exceptions if select/poll are called with an invalid socket:
- `ValueError`: because fd == -1 - `ValueError`: because fd == -1
@@ -68,7 +73,7 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
class PatroniKazooClient(KazooClient): class PatroniKazooClient(KazooClient):
def _call(self, request, async_object): def _call(self, request: Tuple[Any], async_object: AsyncResult) -> Optional[bool]:
# Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if # Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if
# the connection is in the SUSPENDED state and Patroni was strongly relying on it. # the connection is in the SUSPENDED state and Patroni was strongly relying on it.
# The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued. # The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued.
@@ -82,10 +87,10 @@ class PatroniKazooClient(KazooClient):
class ZooKeeper(AbstractDCS): class ZooKeeper(AbstractDCS):
def __init__(self, config): def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(ZooKeeper, self).__init__(config) super(ZooKeeper, self).__init__(config, mpp)
hosts = config.get('hosts', []) hosts: Union[str, List[str]] = config.get('hosts', [])
if isinstance(hosts, list): if isinstance(hosts, list):
hosts = ','.join(hosts) hosts = ','.join(hosts)
@@ -94,34 +99,33 @@ class ZooKeeper(AbstractDCS):
kwargs = {v: config[k] for k, v in mapping.items() if k in config} kwargs = {v: config[k] for k, v in mapping.items() if k in config}
if 'set_acls' in config: if 'set_acls' in config:
kwargs['default_acl'] = [] default_acl: List[ACL] = []
for principal, permissions in config['set_acls'].items(): for principal, permissions in config['set_acls'].items():
normalizedPermissions = [p.upper() for p in permissions] normalizedPermissions = [p.upper() for p in permissions]
kwargs['default_acl'].append(make_acl(scheme='x509', default_acl.append(make_acl(scheme='x509',
credential=principal, credential=principal,
read='READ' in normalizedPermissions, read='READ' in normalizedPermissions,
write='WRITE' in normalizedPermissions, write='WRITE' in normalizedPermissions,
create='CREATE' in normalizedPermissions, create='CREATE' in normalizedPermissions,
delete='DELETE' in normalizedPermissions, delete='DELETE' in normalizedPermissions,
admin='ADMIN' in normalizedPermissions, admin='ADMIN' in normalizedPermissions,
all='ALL' in normalizedPermissions)) all='ALL' in normalizedPermissions))
kwargs['default_acl'] = default_acl
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']), self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1, timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1, sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs) deadline=config['retry_timeout'], sleep_func=time.sleep),
self._client.add_listener(self.session_listener) auth_data=list(config.get('auth_data', {}).items()), **kwargs)
self._fetch_cluster = True self.__last_member_data: Optional[Dict[str, Any]] = None
self._fetch_status = True
self.__last_member_data = None
self._orig_kazoo_connect = self._client._connection._connect self._orig_kazoo_connect = self._client._connection._connect
self._client._connection._connect = self._kazoo_connect self._client._connection._connect = self._kazoo_connect
self._client.start() self._client.start()
def _kazoo_connect(self, *args): def _kazoo_connect(self, *args: Any) -> Tuple[Union[int, float], Union[int, float]]:
"""Kazoo is using Ping's to determine health of connection to zookeeper. If there is no """Kazoo is using Ping's to determine health of connection to zookeeper. If there is no
response on Ping after Ping interval (1/2 from read_timeout) it will consider current response on Ping after Ping interval (1/2 from read_timeout) it will consider current
connection dead and try to connect to another node. Without this "magic" it was taking connection dead and try to connect to another node. Without this "magic" it was taking
@@ -134,32 +138,21 @@ class ZooKeeper(AbstractDCS):
`write_leader_optime()` methods, which also may hang...""" `write_leader_optime()` methods, which also may hang..."""
ret = self._orig_kazoo_connect(*args) ret = self._orig_kazoo_connect(*args)
return max(self.loop_wait - 2, 2)*1000, ret[1] return max(self.loop_wait - 2, 2) * 1000, ret[1]
def session_listener(self, state): def _watcher(self, event: WatchedEvent) -> None:
if state in [KazooState.SUSPENDED, KazooState.LOST]: if event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.cluster_watcher(None) self.event.set()
def status_watcher(self, event): def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
self._fetch_status = True
self.event.set()
def cluster_watcher(self, event):
self._fetch_cluster = True
if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.status_watcher(event)
def members_watcher(self, event):
self._fetch_cluster = True
def reload_config(self, config):
self.set_retry_timeout(config['retry_timeout']) self.set_retry_timeout(config['retry_timeout'])
loop_wait = config['loop_wait'] loop_wait = config['loop_wait']
loop_wait_changed = self._loop_wait != loop_wait loop_wait_changed = self._loop_wait != loop_wait
self._loop_wait = loop_wait self._loop_wait = loop_wait
self._client.handler.set_connect_timeout(loop_wait) if isinstance(self._client.handler, PatroniSequentialThreadingHandler):
self._client.handler.set_connect_timeout(loop_wait)
# We need to reestablish connection to zookeeper if we want to change # We need to reestablish connection to zookeeper if we want to change
# read_timeout (and Ping interval respectively), because read_timeout # read_timeout (and Ping interval respectively), because read_timeout
@@ -170,7 +163,7 @@ class ZooKeeper(AbstractDCS):
if not self.set_ttl(config['ttl']) and loop_wait_changed: if not self.set_ttl(config['ttl']) and loop_wait_changed:
self._client._connection._socket.close() self._client._connection._socket.close()
def set_ttl(self, ttl): def set_ttl(self, ttl: int) -> Optional[bool]:
"""It is not possible to change ttl (session_timeout) in zookeeper without """It is not possible to change ttl (session_timeout) in zookeeper without
destroying old session and creating the new one. This method returns `!True` destroying old session and creating the new one. This method returns `!True`
if session_timeout has been changed (`restart()` has been called).""" if session_timeout has been changed (`restart()` has been called)."""
@@ -181,152 +174,119 @@ class ZooKeeper(AbstractDCS):
return True return True
@property @property
def ttl(self): def ttl(self) -> int:
return self._client._session_timeout / 1000.0 return int(self._client._session_timeout / 1000.0)
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout: int) -> None:
retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry
retry.deadline = retry_timeout retry.deadline = retry_timeout
def get_node(self, key, watch=None): def get_node(
self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None
) -> Optional[Tuple[str, ZnodeStat]]:
try: try:
ret = self._client.get(key, watch) ret = self._client.get(key, watch)
return (ret[0].decode('utf-8'), ret[1]) return (ret[0].decode('utf-8'), ret[1])
except NoNodeError: except NoNodeError:
return None return None
def get_status(self, path, leader): def get_status(self, path: str, leader: Optional[Leader]) -> Status:
watch = self.status_watcher if not leader or leader.name != self._name else None status = self.get_node(path + self._STATUS)
if not status:
status = self.get_node(path + self._STATUS, watch) status = self.get_node(path + self._LEADER_OPTIME)
if status: return Status.from_node(status and status[0])
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)
except Exception:
last_lsn = 0
self._fetch_status = False
return last_lsn, slots
@staticmethod @staticmethod
def member(name, value, znode): def member(name: str, value: str, znode: ZnodeStat) -> Member:
return Member.from_node(znode.version, name, znode.ephemeralOwner, value) return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
def get_children(self, key, watch=None): def get_children(self, key: str) -> List[str]:
try: try:
return self._client.get_children(key, watch) return self._client.get_children(key)
except NoNodeError: except NoNodeError:
return [] return []
def load_members(self, path): def load_members(self, path: str) -> List[Member]:
members = [] members: List[Member] = []
for member in self.get_children(path + self._MEMBERS, self.cluster_watcher): for member in self.get_children(path + self._MEMBERS):
data = self.get_node(path + self._MEMBERS + member) data = self.get_node(path + self._MEMBERS + member)
if data is not None: if data is not None:
members.append(self.member(member, *data)) members.append(self.member(member, *data))
return members return members
def _cluster_loader(self, path): def _postgresql_cluster_loader(self, path: str) -> Cluster:
self._fetch_cluster = False """Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
self.event.clear()
nodes = set(self.get_children(path, self.cluster_watcher)) :param path: the path in DCS where to load :class:`Cluster` from.
if not nodes:
self._fetch_cluster = True :returns: :class:`Cluster` instance.
"""
nodes = set(self.get_children(path))
# get initialize flag # get initialize flag
initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None
# get global dynamic configuration # 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) config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
# get timeline history # 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]) history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
# get synchronization state # 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]) sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
# get list of members # get list of members
members = self.load_members(path) if self._MEMBERS[:-1] in nodes else [] members = self.load_members(path) if self._MEMBERS[:-1] in nodes else []
# get leader # 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: if leader:
member = Member(-1, leader[0], None, {}) member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0] member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member) leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self._fetch_cluster = member.index == -1
# get last known leader lsn and slots # get last known leader lsn and slots
last_lsn, slots = self.get_status(path, leader) status = self.get_status(path, leader)
# failover key # 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]) failover = failover and Failover.from_node(failover[1].version, failover[0])
# get failsafe topology # 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: try:
failsafe = json.loads(failsafe[0]) if failsafe else None failsafe = json.loads(failsafe[0]) if failsafe else None
except Exception: except Exception:
failsafe = None 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): def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
fetch_cluster = False """Load and build all PostgreSQL clusters from a single MPP cluster.
ret = {}
for node in self.get_children(path, self.cluster_watcher): :param path: the path in DCS where to load Cluster(s) from.
if citus_group_re.match(node):
ret[int(node)] = self._cluster_loader(path + node + '/') :returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
fetch_cluster = fetch_cluster or self._fetch_cluster """
self._fetch_cluster = fetch_cluster ret: Dict[int, 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 return ret
def _load_cluster(self, path, loader): def _load_cluster(
cluster = self.cluster if path == self._base_path + '/' else None self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
if self._fetch_cluster or cluster is None: ) -> Union[Cluster, Dict[int, Cluster]]:
try: try:
cluster = self._client.retry(loader, path) return self._client.retry(loader, path)
except Exception: except Exception:
logger.exception('get_cluster') logger.exception('get_cluster')
self.cluster_watcher(None) raise ZooKeeperError('ZooKeeper in not responding properly')
raise ZooKeeperError('ZooKeeper in not responding properly')
# The /status ZNode was updated or doesn't exist
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()
cluster = list(cluster)
cluster[3] = last_lsn
cluster[8] = slots
cluster = Cluster(*cluster)
except Exception:
pass
return cluster
def _bypass_caches(self): def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool:
self._fetch_cluster = True
def _create(self, path, value, retry=False, ephemeral=False):
try: try:
if retry: if retry:
self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral) self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral)
@@ -337,7 +297,7 @@ class ZooKeeper(AbstractDCS):
logger.exception('Failed to create %s', path) logger.exception('Failed to create %s', path)
return False return False
def attempt_to_acquire_leader(self): def attempt_to_acquire_leader(self) -> bool:
try: try:
self._client.retry(self._client.create, self.leader_path, self._name.encode('utf-8'), self._client.retry(self._client.create, self.leader_path, self._name.encode('utf-8'),
makepath=True, ephemeral=True) makepath=True, ephemeral=True)
@@ -350,57 +310,56 @@ class ZooKeeper(AbstractDCS):
logger.info('Could not take out TTL lock') logger.info('Could not take out TTL lock')
return False return False
def _set_or_create(self, key, value, index=None, retry=False, do_not_create_empty=False): def _set_or_create(self, key: str, value: str, version: Optional[int] = None,
value = value.encode('utf-8') retry: bool = False, do_not_create_empty: bool = False) -> Union[int, bool]:
value_bytes = value.encode('utf-8')
try: try:
if retry: if retry:
self._client.retry(self._client.set, key, value, version=index or -1) ret = self._client.retry(self._client.set, key, value_bytes, version=version or -1)
else: else:
self._client.set_async(key, value, version=index or -1).get(timeout=1) ret = self._client.set_async(key, value_bytes, version=version or -1).get(timeout=1)
return True return ret.version
except NoNodeError: except NoNodeError:
if do_not_create_empty and not value: if do_not_create_empty and not value_bytes:
return True return True
elif index is None: elif version is None:
return self._create(key, value, retry) if self._create(key, value_bytes, retry):
return 0
else: else:
return False return False
except Exception: except Exception:
logger.exception('Failed to update %s', key) logger.exception('Failed to update %s', key)
return False return False
def set_failover_value(self, value, index=None): def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
return self._set_or_create(self.failover_path, value, index) return self._set_or_create(self.failover_path, value, version) is not False
def set_config_value(self, value, index=None): def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
return self._set_or_create(self.config_path, value, index, retry=True) return self._set_or_create(self.config_path, value, version, retry=True) is not False
def initialize(self, create_new=True, sysid=""): def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
sysid = sysid.encode('utf-8') sysid_bytes = sysid.encode('utf-8')
return self._create(self.initialize_path, sysid, retry=True) if create_new \ return self._create(self.initialize_path, sysid_bytes, retry=True) if create_new \
else self._client.retry(self._client.set, self.initialize_path, sysid) else self._client.retry(self._client.set, self.initialize_path, sysid_bytes)
def touch_member(self, data): def touch_member(self, data: Dict[str, Any]) -> bool:
cluster = self.cluster cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False) member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
member_data = self.__last_member_data or member and member.data member_data = self.__last_member_data or member and member.data
# We want to notify leader if some important fields in the member key changed by removing ZNode if member and member_data:
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or # We want delete the member ZNode if our session doesn't match with session id on our member key
not (deep_compare(member_data.get('tags', {}), data.get('tags', {})) and if self._client.client_id is not None and member.session != self._client.client_id[0]:
(member_data.get('state') == data.get('state') or logger.warning('Recreating the member ZNode due to ownership mismatch')
'running' not in (member_data.get('state'), data.get('state'))) and try:
member_data.get('version') == data.get('version') and self._client.delete_async(self.member_path).get(timeout=1)
member_data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))): except NoNodeError:
try: pass
self._client.delete_async(self.member_path).get(timeout=1) except Exception:
except NoNodeError: return False
pass member = None
except Exception:
return False
member = None
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8') encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member: if member and member_data:
if deep_compare(data, member_data): if deep_compare(data, member_data):
return True return True
else: else:
@@ -421,22 +380,20 @@ class ZooKeeper(AbstractDCS):
return False return False
def take_leader(self): def take_leader(self) -> bool:
return self.attempt_to_acquire_leader() return self.attempt_to_acquire_leader()
def _write_leader_optime(self, last_lsn): def _write_leader_optime(self, last_lsn: str) -> bool:
return self._set_or_create(self.leader_optime_path, last_lsn) return self._set_or_create(self.leader_optime_path, last_lsn) is not False
def _write_status(self, value): def _write_status(self, value: str) -> bool:
return self._set_or_create(self.status_path, value) return self._set_or_create(self.status_path, value) is not False
def _write_failsafe(self, value): def _write_failsafe(self, value: str) -> bool:
return self._set_or_create(self.failsafe_path, value) return self._set_or_create(self.failsafe_path, value) is not False
def _update_leader(self): def _update_leader(self, leader: Leader) -> bool:
cluster = self.cluster if self._client.client_id and self._client.client_id[0] != leader.session:
session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
if self._client.client_id and self._client.client_id[0] != session:
logger.warning('Recreating the leader ZNode due to ownership mismatch') logger.warning('Recreating the leader ZNode due to ownership mismatch')
try: try:
self._client.retry(self._client.delete, self.leader_path) self._client.retry(self._client.delete, self.leader_path)
@@ -458,38 +415,43 @@ class ZooKeeper(AbstractDCS):
return False return False
return True return True
def _delete_leader(self): def _delete_leader(self, leader: Leader) -> bool:
self._client.restart() self._client.restart()
return True return True
def _cancel_initialization(self): def _cancel_initialization(self) -> None:
node = self.get_node(self.initialize_path) node = self.get_node(self.initialize_path)
if node: if node:
self._client.delete(self.initialize_path, version=node[1].version) self._client.delete(self.initialize_path, version=node[1].version)
def cancel_initialization(self): def cancel_initialization(self) -> bool:
try: try:
self._client.retry(self._cancel_initialization) self._client.retry(self._cancel_initialization)
return True
except Exception: except Exception:
logger.exception("Unable to delete initialize key") logger.exception("Unable to delete initialize key")
return False
def delete_cluster(self): def delete_cluster(self) -> bool:
try: try:
return self._client.retry(self._client.delete, self.client_path(''), recursive=True) return self._client.retry(self._client.delete, self.client_path(''), recursive=True)
except NoNodeError: except NoNodeError:
return True return True
def set_history_value(self, value): def set_history_value(self, value: str) -> bool:
return self._set_or_create(self.history_path, value) return self._set_or_create(self.history_path, value) is not False
def set_sync_state_value(self, value, index=None): def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
return self._set_or_create(self.sync_path, value, index, retry=True, do_not_create_empty=True) return self._set_or_create(self.sync_path, value, version, retry=True, do_not_create_empty=True)
def delete_sync_state(self, index=None): def delete_sync_state(self, version: Optional[int] = None) -> bool:
return self.set_sync_state_value("{}", index) return self.set_sync_state_value("{}", version) is not False
def watch(self, leader_index, timeout): def watch(self, leader_version: Optional[int], timeout: float) -> bool:
ret = super(ZooKeeper, self).watch(leader_index, timeout + 0.5) if leader_version:
if ret and not self._fetch_status: timeout += 0.5
self._fetch_cluster = True
return ret or self._fetch_cluster 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)
+27 -9
View File
@@ -1,37 +1,55 @@
"""Implement high-level Patroni exceptions.
More specific exceptions can be found in other modules, as subclasses of any exception defined in this module.
"""
from typing import Any
class PatroniException(Exception): class PatroniException(Exception):
"""Parent class for all kind of Patroni exceptions.
"""Parent class for all kind of exceptions related to selected distributed configuration store""" :ivar value: description of the exception.
"""
def __init__(self, value): def __init__(self, value: Any) -> None:
"""Create a new instance of :class:`PatroniException` with the given description.
:param value: description of the exception.
"""
self.value = value self.value = value
def __str__(self):
"""
>>> str(PatroniException('foo'))
"'foo'"
"""
return repr(self.value)
class PatroniFatalException(PatroniException): class PatroniFatalException(PatroniException):
"""Catastrophic exception that prevents Patroni from performing its job."""
pass pass
class PostgresException(PatroniException): class PostgresException(PatroniException):
"""Any exception related with Postgres management."""
pass pass
class DCSError(PatroniException): class DCSError(PatroniException):
"""Parent class for all kind of DCS related exceptions."""
pass pass
class PostgresConnectionException(PostgresException): class PostgresConnectionException(PostgresException):
"""Any problem faced while connecting to a Postgres instance."""
pass pass
class WatchdogError(PatroniException): class WatchdogError(PatroniException):
"""Any problem faced while managing a watchdog device."""
pass pass
class ConfigParseError(PatroniException): class ConfigParseError(PatroniException):
"""Any issue identified while loading or validating the Patroni configuration."""
pass pass
+95
View File
@@ -0,0 +1,95 @@
"""Helper object that helps with figuring out file and directory permissions based on permissions of PGDATA.
:var logger: logger of this module.
:var pg_perm: instance of the :class:`__FilePermissions` object.
"""
import logging
import os
import stat
logger = logging.getLogger(__name__)
class __FilePermissions:
"""Helper class for managing permissions of directories and files under PGDATA.
Execute :meth:`set_permissions_from_data_directory` to figure out which permissions should be used for files and
directories under PGDATA based on permissions of PGDATA root directory.
"""
# Mode mask for data directory permissions that only allows the owner to
# read/write directories and files -- mask 077.
__PG_MODE_MASK_OWNER = stat.S_IRWXG | stat.S_IRWXO
# Mode mask for data directory permissions that also allows group read/execute -- mask 027.
__PG_MODE_MASK_GROUP = stat.S_IWGRP | stat.S_IRWXO
# Default mode for creating directories -- mode 700.
__PG_DIR_MODE_OWNER = stat.S_IRWXU
# Mode for creating directories that allows group read/execute -- mode 750.
__PG_DIR_MODE_GROUP = stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP
# Default mode for creating files -- mode 600.
__PG_FILE_MODE_OWNER = stat.S_IRUSR | stat.S_IWUSR
# Mode for creating files that allows group read -- mode 640.
__PG_FILE_MODE_GROUP = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
def __init__(self) -> None:
"""Create a :class:`__FilePermissions` object and set default permissions."""
self.__set_owner_permissions()
self.__set_umask()
def __set_umask(self) -> None:
"""Set umask value based on calculations.
.. note::
Should only be called once either :meth:`__set_owner_permissions`
or :meth:`__set_group_permissions` has been executed.
"""
try:
os.umask(self.__pg_mode_mask)
except Exception as e:
logger.error('Can not set umask to %03o: %r', self.__pg_mode_mask, e)
def __set_owner_permissions(self) -> None:
"""Make directories/files accessible only by the owner."""
self.__pg_dir_create_mode = self.__PG_DIR_MODE_OWNER
self.__pg_file_create_mode = self.__PG_FILE_MODE_OWNER
self.__pg_mode_mask = self.__PG_MODE_MASK_OWNER
def __set_group_permissions(self) -> None:
"""Make directories/files accessible by the owner and readable by group."""
self.__pg_dir_create_mode = self.__PG_DIR_MODE_GROUP
self.__pg_file_create_mode = self.__PG_FILE_MODE_GROUP
self.__pg_mode_mask = self.__PG_MODE_MASK_GROUP
def set_permissions_from_data_directory(self, data_dir: str) -> None:
"""Set new permissions based on provided *data_dir*.
:param data_dir: reference to PGDATA to calculate permissions from.
"""
try:
st = os.stat(data_dir)
if (st.st_mode & self.__PG_DIR_MODE_GROUP) == self.__PG_DIR_MODE_GROUP:
self.__set_group_permissions()
else:
self.__set_owner_permissions()
except Exception as e:
logger.error('Can not check permissions on %s: %r', data_dir, e)
else:
self.__set_umask()
@property
def dir_create_mode(self) -> int:
"""Directory permissions."""
return self.__pg_dir_create_mode
@property
def file_create_mode(self) -> int:
"""File permissions."""
return self.__pg_file_create_mode
pg_perm = __FilePermissions()
+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()
+708 -467
View File
File diff suppressed because it is too large Load Diff
+355 -48
View File
@@ -1,45 +1,96 @@
"""Patroni logging facilities.
Daemon processes will use a 2-step logging handler. Whenever a log message is issued it is initially enqueued in-memory
and is later asynchronously flushed by a thread to the final destination.
"""
import logging import logging
import os import os
import sys import sys
from copy import deepcopy from copy import deepcopy
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from patroni.utils import deep_compare
from queue import Queue, Full from queue import Queue, Full
from threading import Lock, Thread from threading import Lock, Thread
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .utils import deep_compare
type_logformat = Union[List[Union[str, Dict[str, Any], Any]], str, Any]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def debug_exception(logger_obj, msg, *args, **kwargs): def debug_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
"""Add full stack trace info to debug log messages and partial to others.
Handle :func:`~self.exception` calls for *self*.
.. note::
* If *self* log level is set to ``DEBUG``, then issue a ``DEBUG`` message with the complete stack trace;
* If *self* log level is ``INFO`` or higher, then issue an ``ERROR`` message with only the last line of
the stack trace.
:param self: logger for which :func:`~self.exception` will be processed.
:param msg: the message related to the exception to be logged.
:param args: positional arguments to be passed to :func:`~self.debug` or :func:`~self.error`.
:param kwargs: keyword arguments to be passed to :func:`~self.debug` or :func:`~self.error`.
"""
kwargs.pop("exc_info", False) kwargs.pop("exc_info", False)
if logger_obj.isEnabledFor(logging.DEBUG): if self.isEnabledFor(logging.DEBUG):
logger_obj.debug(msg, *args, exc_info=True, **kwargs) self.debug(msg, *args, exc_info=True, **kwargs)
else: else:
msg = "{0}, DETAIL: '{1}'".format(msg, sys.exc_info()[1]) msg = "{0}, DETAIL: '{1}'".format(msg, sys.exc_info()[1])
logger_obj.error(msg, *args, exc_info=False, **kwargs) self.error(msg, *args, exc_info=False, **kwargs)
def error_exception(logger_obj, msg, *args, **kwargs): def error_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
"""Add full stack trace info to error messages.
Handle :func:`~self.exception` calls for *self*.
.. note::
* By default issue an ``ERROR`` message with the complete stack trace. If you do not want to show the complete
stack trace, call with ``exc_info=False``.
:param self: logger for which :func:`~self.exception` will be processed.
:param msg: the message related to the exception to be logged.
:param args: positional arguments to be passed to :func:`~self.error`.
:param kwargs: keyword arguments to be passed to :func:`~self.error`.
"""
exc_info = kwargs.pop("exc_info", True) exc_info = kwargs.pop("exc_info", True)
logger_obj.error(msg, *args, exc_info=exc_info, **kwargs) self.error(msg, *args, exc_info=exc_info, **kwargs)
class QueueHandler(logging.Handler): class QueueHandler(logging.Handler):
"""Queue-based logging handler.
def __init__(self): :ivar queue: queue to hold log messages that are pending to be flushed to the final destination.
logging.Handler.__init__(self) """
self.queue = Queue()
def __init__(self) -> None:
"""Queue initialised and initial records_lost established."""
super().__init__()
self.queue: Queue[Union[logging.LogRecord, None]] = Queue()
self._records_lost = 0 self._records_lost = 0
def _put_record(self, record): def _put_record(self, record: logging.LogRecord) -> None:
"""Asynchronously enqueue a log record.
:param record: the record to be logged.
"""
self.format(record) self.format(record)
record.msg = record.message record.msg = record.message
record.args = None record.args = None
record.exc_info = None record.exc_info = None
self.queue.put_nowait(record) self.queue.put_nowait(record)
def _try_to_report_lost_records(self): def _try_to_report_lost_records(self) -> None:
"""Report the number of log messages that have been lost and reset the counter.
.. note::
It will issue an ``WARNING`` message in the logs with the number of lost log messages.
"""
if self._records_lost: if self._records_lost:
try: try:
record = _LOGGER.makeRecord(_LOGGER.name, logging.WARNING, __file__, 0, record = _LOGGER.makeRecord(_LOGGER.name, logging.WARNING, __file__, 0,
@@ -50,7 +101,15 @@ class QueueHandler(logging.Handler):
except Exception: except Exception:
pass pass
def emit(self, record): def emit(self, record: logging.LogRecord) -> None:
"""Handle each log record that is emitted.
Call :func:`_put_record` to enqueue the emitted log record.
Also check if we have previously lost any log record, and if so, log a ``WARNING`` message.
:param record: the record that was emitted.
"""
try: try:
self._put_record(record) self._put_record(record)
self._try_to_report_lost_records() self._try_to_report_lost_records()
@@ -58,22 +117,63 @@ class QueueHandler(logging.Handler):
self._records_lost += 1 self._records_lost += 1
@property @property
def records_lost(self): def records_lost(self) -> int:
"""Number of log messages that have been lost while the queue was full."""
return self._records_lost return self._records_lost
class ProxyHandler(logging.Handler): class ProxyHandler(logging.Handler):
"""Handle log records in place of pending log handlers.
def __init__(self, patroni_logger): .. note::
logging.Handler.__init__(self) This is used to handle log messages while the logger thread has not started yet, in which case the queue-based
handler is not yet started.
:ivar patroni_logger: the logger thread.
"""
def __init__(self, patroni_logger: 'PatroniLogger') -> None:
"""Create a new :class:`ProxyHandler` instance.
:param patroni_logger: the logger thread.
"""
super().__init__()
self.patroni_logger = patroni_logger self.patroni_logger = patroni_logger
def emit(self, record): def emit(self, record: logging.LogRecord) -> None:
self.patroni_logger.log_handler.handle(record) """Emit each log record that is handled.
Will push the log record down to :func:`~logging.Handler.handle` method of the currently configured log handler.
:param record: the record that was emitted.
"""
if self.patroni_logger.log_handler is not None:
self.patroni_logger.log_handler.handle(record)
class PatroniLogger(Thread): class PatroniLogger(Thread):
"""Logging thread for the Patroni daemon process.
It is a 2-step logging approach. Any time a log message is issued it is initially enqueued in-memory, and then
asynchronously flushed to the final destination by the logging thread.
.. seealso::
:class:`QueueHandler`: object used for enqueueing messages in-memory.
:cvar DEFAULT_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``).
:cvar NORMAL_LOG_QUEUE_SIZE: expected number of log messages per HA loop when operating under a normal situation.
:cvar DEFAULT_MAX_QUEUE_SIZE: default maximum queue size for holding a backlog of log messages that are pending
to be flushed.
:cvar LOGGING_BROKEN_EXIT_CODE: exit code to be used if it detects(``5``).
:ivar log_handler: log handler that is currently being used by the thread.
:ivar log_handler_lock: lock used to modify ``log_handler``.
"""
DEFAULT_TYPE = 'plain'
DEFAULT_LEVEL = 'INFO' DEFAULT_LEVEL = 'INFO'
DEFAULT_TRACEBACK_LEVEL = 'ERROR' DEFAULT_TRACEBACK_LEVEL = 'ERROR'
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s' DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
@@ -82,14 +182,24 @@ class PatroniLogger(Thread):
DEFAULT_MAX_QUEUE_SIZE = 1000 DEFAULT_MAX_QUEUE_SIZE = 1000
LOGGING_BROKEN_EXIT_CODE = 5 LOGGING_BROKEN_EXIT_CODE = 5
def __init__(self): def __init__(self) -> None:
"""Prepare logging queue and proxy handlers as they become ready during daemon startup.
.. note::
While Patroni is starting up it keeps ``DEBUG`` log level, and writes log messages through a proxy handler.
Once the logger thread is finally started, it switches from that proxy handler to the queue based logger,
and applies the configured log settings. The switching is used to avoid that the logger thread prevents
Patroni from shutting down if any issue occurs in the meantime until the thread is properly started.
"""
super(PatroniLogger, self).__init__() super(PatroniLogger, self).__init__()
self._queue_handler = QueueHandler() self._queue_handler = QueueHandler()
self._root_logger = logging.getLogger() self._root_logger = logging.getLogger()
self._config = None self._config: Optional[Dict[str, Any]] = None
self.log_handler = None self.log_handler = None
self.log_handler_lock = Lock() self.log_handler_lock = Lock()
self._old_handlers = [] self._old_handlers: List[logging.Handler] = []
# initially set log level to ``DEBUG`` while the logger thread has not started running yet. The daemon process
# will later adjust all log related settings with what was provided through the user configuration file.
self.reload_config({'level': 'DEBUG'}) self.reload_config({'level': 'DEBUG'})
# We will switch to the QueueHandler only when thread was started. # We will switch to the QueueHandler only when thread was started.
# This is necessary to protect from the cases when Patroni constructor # This is necessary to protect from the cases when Patroni constructor
@@ -97,59 +207,243 @@ class PatroniLogger(Thread):
self._proxy_handler = ProxyHandler(self) self._proxy_handler = ProxyHandler(self)
self._root_logger.addHandler(self._proxy_handler) self._root_logger.addHandler(self._proxy_handler)
def update_loggers(self): def update_loggers(self, config: Dict[str, Any]) -> None:
loggers = deepcopy(self._config.get('loggers') or {}) """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(config)
for name, logger in self._root_logger.manager.loggerDict.items(): for name, logger in self._root_logger.manager.loggerDict.items():
# ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only
# in the ones that were defined
if not isinstance(logger, logging.PlaceHolder): if not isinstance(logger, logging.PlaceHolder):
# if this logger is present in *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) level = loggers.pop(name, logging.NOTSET)
logger.setLevel(level) logger.setLevel(level)
# define loggers that do not exist yet and set level as configured in the *config*
for name, level in loggers.items(): for name, level in loggers.items():
logger = self._root_logger.manager.getLogger(name) logger = self._root_logger.manager.getLogger(name)
logger.setLevel(level) logger.setLevel(level)
def reload_config(self, config): def _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.
.. note::
It is also able to deal with runtime configuration changes.
:param config: ``log`` section from Patroni configuration.
"""
if self._config is None or not deep_compare(self._config, config): if self._config is None or not deep_compare(self._config, config):
with self._queue_handler.queue.mutex: with self._queue_handler.queue.mutex:
self._queue_handler.queue.maxsize = config.get('max_queue_size', self.DEFAULT_MAX_QUEUE_SIZE) self._queue_handler.queue.maxsize = config.get('max_queue_size', self.DEFAULT_MAX_QUEUE_SIZE)
self._root_logger.setLevel(config.get('level', PatroniLogger.DEFAULT_LEVEL)) self._root_logger.setLevel(config.get('level', PatroniLogger.DEFAULT_LEVEL))
if config.get('traceback_level', PatroniLogger.DEFAULT_TRACEBACK_LEVEL).lower() == 'debug': if config.get('traceback_level', PatroniLogger.DEFAULT_TRACEBACK_LEVEL).lower() == 'debug':
# show stack traces only if ``log.traceback_level`` is ``DEBUG``
logging.Logger.exception = debug_exception logging.Logger.exception = debug_exception
else: else:
# show stack traces as ``ERROR`` log messages
logging.Logger.exception = error_exception logging.Logger.exception = error_exception
new_handler = None handler = self.log_handler
if 'dir' in config: if 'dir' in config:
if not isinstance(self.log_handler, RotatingFileHandler): if not isinstance(handler, RotatingFileHandler):
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__)) handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler = new_handler or self.log_handler
handler.maxBytes = int(config.get('file_size', 25000000)) 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)) handler.backupCount = int(config.get('file_num', 4))
else: # we can't use `if not isinstance(handler, logging.StreamHandler)` below,
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler): # because RotatingFileHandler is a child of StreamHandler!!!
new_handler = logging.StreamHandler() elif handler is None or isinstance(handler, RotatingFileHandler):
handler = new_handler or self.log_handler handler = logging.StreamHandler()
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT) is_new_handler = handler != self.log_handler
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
olddateformat = (self._config or {}).get('dateformat') or None if (self._is_config_changed(config) or is_new_handler) and handler:
dateformat = config.get('dateformat') or None # Convert empty string to `None` formatter = self._get_formatter(config)
handler.setFormatter(formatter)
if oldlogformat != logformat or olddateformat != dateformat or new_handler: if is_new_handler:
handler.setFormatter(logging.Formatter(logformat, dateformat))
if new_handler:
with self.log_handler_lock: with self.log_handler_lock:
if self.log_handler: if self.log_handler:
self._old_handlers.append(self.log_handler) self._old_handlers.append(self.log_handler)
self.log_handler = new_handler self.log_handler = handler
self._config = config.copy() self._config = config.copy()
self.update_loggers() self.update_loggers(config.get('loggers') or {})
def _close_old_handlers(self): def _close_old_handlers(self) -> None:
"""Close old log handlers.
.. note::
It is used to remove different handlers that were configured previous to a reload in the configuration,
e.g. if we are switching from :class:`~logging.handlers.RotatingFileHandler` to
class:`~logging.StreamHandler` and vice-versa.
"""
while True: while True:
with self.log_handler_lock: with self.log_handler_lock:
if not self._old_handlers: if not self._old_handlers:
@@ -160,7 +454,11 @@ class PatroniLogger(Thread):
except Exception: except Exception:
_LOGGER.exception('Failed to close the old log handler %s', handler) _LOGGER.exception('Failed to close the old log handler %s', handler)
def run(self): def run(self) -> None:
"""Run logger's thread main loop.
Keep consuming log queue until requested to quit through ``None`` special log record.
"""
# switch to QueueHandler only when the thread was started # switch to QueueHandler only when the thread was started
with self.log_handler_lock: with self.log_handler_lock:
self._root_logger.addHandler(self._queue_handler) self._root_logger.addHandler(self._queue_handler)
@@ -170,12 +468,17 @@ class PatroniLogger(Thread):
while True: while True:
self._close_old_handlers() self._close_old_handlers()
if TYPE_CHECKING: # pragma: no cover
assert self.log_handler is not None
record = self._queue_handler.queue.get(True) record = self._queue_handler.queue.get(True)
# special message that indicates Patroni is shutting down
if record is None: if record is None:
break break
if self._root_logger.level == logging.INFO: if self._root_logger.level == logging.INFO:
# messages like ``Lock owner: postgresql0; I am postgresql1`` will be shown only when stream doesn't
# look normal. This is used to reduce chattiness of Patroni logs.
if record.msg.startswith('Lock owner: '): if record.msg.startswith('Lock owner: '):
prev_record, record = record, None prev_record, record = record, None
else: else:
@@ -189,8 +492,10 @@ class PatroniLogger(Thread):
self._queue_handler.queue.task_done() self._queue_handler.queue.task_done()
def shutdown(self): def shutdown(self) -> None:
"""Shut down the logger thread."""
try: try:
# ``None`` is a special message indicating to queue handler that it should quit its main loop.
self._queue_handler.queue.put_nowait(None) self._queue_handler.queue.put_nowait(None)
except Full: # Queue is full. except Full: # Queue is full.
# It seems that logging is not working, exiting with non-standard exit-code is the best we can do. # It seems that logging is not working, exiting with non-standard exit-code is the best we can do.
@@ -199,9 +504,11 @@ class PatroniLogger(Thread):
logging.shutdown() logging.shutdown()
@property @property
def queue_size(self): def queue_size(self) -> int:
"""Number of log records in the queue."""
return self._queue_handler.queue.qsize() return self._queue_handler.queue.qsize()
@property @property
def records_lost(self): def records_lost(self) -> int:
"""Number of logging records that have been lost while the queue was full."""
return self._queue_handler.records_lost return self._queue_handler.records_lost
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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)
+130 -34
View File
@@ -4,51 +4,108 @@ import shlex
import tempfile import tempfile
import time import time
from ..dcs import RemoteMember from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember
from ..psycopg import quote_ident, quote_literal from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare from ..utils import deep_compare, unquote
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Bootstrap(object): class Bootstrap(object):
def __init__(self, postgresql): def __init__(self, postgresql: 'Postgresql') -> None:
self._postgresql = postgresql self._postgresql = postgresql
self._running_custom_bootstrap = False self._running_custom_bootstrap = False
@property @property
def running_custom_bootstrap(self): def running_custom_bootstrap(self) -> bool:
return self._running_custom_bootstrap return self._running_custom_bootstrap
@property @property
def keep_existing_recovery_conf(self): def keep_existing_recovery_conf(self) -> bool:
return self._running_custom_bootstrap and self._keep_existing_recovery_conf return self._running_custom_bootstrap and self._keep_existing_recovery_conf
@staticmethod @staticmethod
def process_user_options(tool, options, not_allowed_options, error_handler): def process_user_options(tool: str,
user_options = [] options: Union[Any, Dict[str, str], List[Union[str, Dict[str, Any]]]],
not_allowed_options: Tuple[str, ...],
error_handler: Callable[[str], None]) -> List[str]:
"""Format *options* in a list or dictionary format into command line long form arguments.
def option_is_allowed(name): .. note::
The format of the output of this method is to prepare arguments for use in the ``initdb``
method of `self._postgres`.
:Example:
The *options* can be defined as a dictionary of key, values to be converted into arguments:
>>> Bootstrap.process_user_options('foo', {'foo': 'bar'}, (), print)
['--foo=bar']
Or as a list of single string arguments
>>> Bootstrap.process_user_options('foo', ['yes'], (), print)
['--yes']
Or as a list of key, value options
>>> Bootstrap.process_user_options('foo', [{'foo': 'bar'}], (), print)
['--foo=bar']
Or a combination of single and key, values
>>> Bootstrap.process_user_options('foo', ['yes', {'foo': 'bar'}], (), print)
['--yes', '--foo=bar']
Options that contain spaces are passed as is to ``subprocess.call``
>>> Bootstrap.process_user_options('foo', [{'foo': 'bar baz'}], (), print)
['--foo=bar baz']
Options that are quoted will be unquoted, so the quotes aren't interpreted
literally by the postgres command
>>> Bootstrap.process_user_options('foo', [{'foo': '"bar baz"'}], (), print)
['--foo=bar baz']
.. note::
The *error_handler* is called when any of these conditions are met:
* Key, value dictionaries in the list form contains multiple keys.
* If a key is listed in *not_allowed_options*.
* If the options list is not in the required structure.
:param tool: The name of the tool used in error reports to *error_handler*
:param options: Options to parse as a list of key, values or single values, or a dictionary
:param not_allowed_options: List of keys that cannot be used in the list of key, value formatted options
:param error_handler: A function which will be called when an error condition is encountered
:returns: List of long form arguments to pass to the named tool
"""
user_options: List[str] = []
def option_is_allowed(name: str) -> bool:
ret = name not in not_allowed_options ret = name not in not_allowed_options
if not ret: if not ret:
error_handler('{0} option for {1} is not allowed'.format(name, tool)) error_handler('{0} option for {1} is not allowed'.format(name, tool))
return ret return ret
if isinstance(options, dict): if isinstance(options, dict):
for k, v in options.items(): for key, val in options.items():
if k and v: if key and val:
user_options.append('--{0}={1}'.format(k, v)) user_options.append('--{0}={1}'.format(key, unquote(val)))
elif isinstance(options, list): elif isinstance(options, list):
for opt in options: for opt in options:
if isinstance(opt, str) and option_is_allowed(opt): if isinstance(opt, str) and option_is_allowed(opt):
user_options.append('--{0}'.format(opt)) user_options.append('--{0}'.format(opt))
elif isinstance(opt, dict): elif isinstance(opt, dict):
keys = list(opt.keys()) 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' error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
' and value should be a string'.format(tool, opt[keys[0]])) ' and value should be a string'.format(tool, opt[keys[0]]))
user_options.append('--{0}={1}'.format(keys[0], opt[keys[0]]))
else: else:
error_handler('Error when parsing {0} option {1}: value should be string value' error_handler('Error when parsing {0} option {1}: value should be string value'
' or a single key-value pair'.format(tool, opt)) ' or a single key-value pair'.format(tool, opt))
@@ -56,11 +113,11 @@ class Bootstrap(object):
error_handler('{0} options must be list or dict'.format(tool)) error_handler('{0} options must be list or dict'.format(tool))
return user_options return user_options
def _initdb(self, config): def _initdb(self, config: Any) -> bool:
self._postgresql.set_state('initializing new cluster') self._postgresql.set_state('initializing new cluster')
not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version') not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version')
def error_handler(e): def error_handler(e: str) -> None:
raise Exception(e) raise Exception(e)
options = self.process_user_options('initdb', config or [], not_allowed_options, error_handler) options = self.process_user_options('initdb', config or [], not_allowed_options, error_handler)
@@ -74,9 +131,8 @@ class Bootstrap(object):
os.write(fd, self._postgresql.config.superuser['password'].encode('utf-8')) os.write(fd, self._postgresql.config.superuser['password'].encode('utf-8'))
os.close(fd) os.close(fd)
options.append('--pwfile={0}'.format(pwfile)) options.append('--pwfile={0}'.format(pwfile))
options = ['-o', ' '.join(options)] if options else []
ret = self._postgresql.pg_ctl('initdb', *options) ret = self._postgresql.initdb(*options)
if pwfile: if pwfile:
os.remove(pwfile) os.remove(pwfile)
if ret: if ret:
@@ -85,21 +141,58 @@ class Bootstrap(object):
self._postgresql.set_state('initdb failed') self._postgresql.set_state('initdb failed')
return ret return ret
def _post_restore(self): def _post_restore(self) -> None:
self._postgresql.config.restore_configuration_files() self._postgresql.config.restore_configuration_files()
self._postgresql.configure_server_parameters() self._postgresql.configure_server_parameters()
# make sure there is no trigger file or postgres will be automatically promoted # make sure there is no trigger file or postgres will be automatically promoted
trigger_file = self._postgresql.config.triggerfile_good_name 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)) trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file): if os.path.exists(trigger_file):
os.unlink(trigger_file) os.unlink(trigger_file)
def _custom_bootstrap(self, config): 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') self._postgresql.set_state('running custom bootstrap script')
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope, params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
'--datadir=' + self._postgresql.data_dir] '--datadir=' + self._postgresql.data_dir]
# 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: try:
logger.info('Running custom bootstrap script: %s', config['command']) logger.info('Running custom bootstrap script: %s', config['command'])
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0: if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
@@ -116,13 +209,13 @@ class Bootstrap(object):
self._postgresql.config.remove_recovery_conf() self._postgresql.config.remove_recovery_conf()
return True return True
def call_post_bootstrap(self, config): def call_post_bootstrap(self, config: Dict[str, Any]) -> bool:
""" """
runs a script after initdb or custom bootstrap script is called and waits until completion. runs a script after initdb or custom bootstrap script is called and waits until completion.
""" """
cmd = config.get('post_bootstrap') or config.get('post_init') cmd = config.get('post_bootstrap') or config.get('post_init')
if cmd: if cmd:
r = self._postgresql.config.local_connect_kwargs r = self._postgresql.connection_pool.conn_kwargs
connstring = self._postgresql.config.format_dsn(r, True) connstring = self._postgresql.config.format_dsn(r, True)
if 'host' not in r: if 'host' not in r:
# https://www.postgresql.org/docs/current/static/libpq-pgpass.html # https://www.postgresql.org/docs/current/static/libpq-pgpass.html
@@ -131,7 +224,7 @@ class Bootstrap(object):
r['host'] = 'localhost' # set it to localhost to write into pgpass r['host'] = 'localhost' # set it to localhost to write into pgpass
env = self._postgresql.config.write_pgpass(r) env = self._postgresql.config.write_pgpass(r)
env['PGOPTIONS'] = '-c synchronous_commit=local' env['PGOPTIONS'] = '-c synchronous_commit=local -c statement_timeout=0'
try: try:
ret = self._postgresql.cancellable.call(shlex.split(cmd) + [connstring], env=env) ret = self._postgresql.cancellable.call(shlex.split(cmd) + [connstring], env=env)
@@ -143,7 +236,7 @@ class Bootstrap(object):
return False return False
return True return True
def create_replica(self, clone_member): def create_replica(self, clone_member: Union[Leader, Member, None]) -> Optional[int]:
""" """
create the replica according to the replica_method create the replica according to the replica_method
defined by the user. this is a list, so we need to defined by the user. this is a list, so we need to
@@ -230,7 +323,7 @@ class Bootstrap(object):
self._postgresql.set_state('stopped') self._postgresql.set_state('stopped')
return ret return ret
def basebackup(self, conn_url, env, options): def basebackup(self, conn_url: str, env: Dict[str, str], options: Dict[str, Any]) -> Optional[int]:
# creates a replica data dir using pg_basebackup. # creates a replica data dir using pg_basebackup.
# this is the default, built-in create_replica_methods # this is the default, built-in create_replica_methods
# tries twice, then returns failure (as 1) # tries twice, then returns failure (as 1)
@@ -265,7 +358,7 @@ class Bootstrap(object):
return ret return ret
def clone(self, clone_member): def clone(self, clone_member: Union[Leader, Member, None]) -> bool:
""" """
- initialize the replica from an existing member (primary or replica) - initialize the replica from an existing member (primary or replica)
- initialize the replica using the replica creation method that - initialize the replica using the replica creation method that
@@ -278,7 +371,7 @@ class Bootstrap(object):
self._post_restore() self._post_restore()
return ret return ret
def bootstrap(self, config): def bootstrap(self, config: Dict[str, Any]) -> bool:
""" Initialize a new node from scratch and start it. """ """ Initialize a new node from scratch and start it. """
pg_hba = config.get('pg_hba', []) pg_hba = config.get('pg_hba', [])
method = config.get('method') or 'initdb' method = config.get('method') or 'initdb'
@@ -290,9 +383,9 @@ class Bootstrap(object):
method = 'initdb' method = 'initdb'
do_initialize = self._initdb do_initialize = self._initdb
return do_initialize(config.get(method)) and self._postgresql.config.append_pg_hba(pg_hba) \ return do_initialize(config.get(method)) and self._postgresql.config.append_pg_hba(pg_hba) \
and self._postgresql.config.save_configuration_files() and self._postgresql.start() and self._postgresql.config.save_configuration_files() and bool(self._postgresql.start())
def create_or_update_role(self, name, password, options): def create_or_update_role(self, name: str, password: Optional[str], options: List[str]) -> None:
options = list(map(str.upper, options)) options = list(map(str.upper, options))
if 'NOLOGIN' not in options and 'LOGIN' not in options: if 'NOLOGIN' not in options and 'LOGIN' not in options:
options.append('LOGIN') options.append('LOGIN')
@@ -322,7 +415,7 @@ END;$$""".format(quote_literal(name), quote_ident(name, self._postgresql.connect
self._postgresql.query('RESET log_statement') self._postgresql.query('RESET log_statement')
self._postgresql.query('RESET pg_stat_statements.track_utility') self._postgresql.query('RESET pg_stat_statements.track_utility')
def post_bootstrap(self, config, task): def post_bootstrap(self, config: Dict[str, Any], task: CriticalTask) -> Optional[bool]:
try: try:
postgresql = self._postgresql postgresql = self._postgresql
superuser = postgresql.config.superuser superuser = postgresql.config.superuser
@@ -346,7 +439,10 @@ BEGIN
END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection())) END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.query(sql) 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)): if all(name != a.get('username') for a in (superuser, replication, rewind)):
self.create_or_update_role(name, value.get('password'), value.get('options', [])) self.create_or_update_role(name, value.get('password'), value.get('options', []))
@@ -369,15 +465,15 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.restart() postgresql.restart()
else: else:
postgresql.config.replace_pg_hba() postgresql.config.replace_pg_hba()
if postgresql.pending_restart: if postgresql.pending_restart_reason:
postgresql.restart() postgresql.restart()
else: else:
postgresql.reload() postgresql.reload()
time.sleep(1) # give a time to postgres to "reload" configuration files time.sleep(1) # give a time to postgres to "reload" configuration files
postgresql.connection().close() # close connection to reconnect with a new password postgresql.connection().close() # close connection to reconnect with a new password
else: # initdb else: # initdb
# We may want create database and extension for citus # We may want create database and extension for some MPP clusters
self._postgresql.citus_handler.bootstrap() self._postgresql.mpp_handler.bootstrap()
except Exception: except Exception:
logger.exception('post_bootstrap') logger.exception('post_bootstrap')
task.complete(False) task.complete(False)
+16 -9
View File
@@ -1,8 +1,9 @@
import logging import logging
import sys
from enum import Enum from enum import Enum
from threading import Condition, Thread from threading import Condition, Thread
from typing import List from typing import Any, Dict, List
from .cancellable import CancellableExecutor, CancellableSubprocess from .cancellable import CancellableExecutor, CancellableSubprocess
@@ -17,7 +18,7 @@ class CallbackAction(str, Enum):
ON_RELOAD = "on_reload" ON_RELOAD = "on_reload"
ON_ROLE_CHANGE = "on_role_change" ON_ROLE_CHANGE = "on_role_change"
def __repr__(self): def __repr__(self) -> str:
return self.value return self.value
@@ -30,7 +31,9 @@ class OnReloadExecutor(CancellableSubprocess):
self.cancel(kill=True) self.cancel(kill=True)
self._kill_children() self._kill_children()
with self._lock: 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): class CallbackExecutor(CancellableExecutor, Thread):
@@ -51,6 +54,8 @@ class CallbackExecutor(CancellableExecutor, Thread):
If it couldn't be killed we wait until it finishes. If it couldn't be killed we wait until it finishes.
:param cmd: command to be executed""" :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: if cmd[-3] == CallbackAction.ON_RELOAD:
return self._on_reload_executor.call_nowait(cmd) return self._on_reload_executor.call_nowait(cmd)
@@ -60,15 +65,17 @@ class CallbackExecutor(CancellableExecutor, Thread):
self._cmd = cmd self._cmd = cmd
self._condition.notify() self._condition.notify()
def run(self): def run(self) -> None:
while True: while True:
with self._condition: with self._condition:
if self._cmd is None: if self._cmd is None:
self._condition.wait() self._condition.wait()
cmd, self._cmd = self._cmd, None cmd, self._cmd = self._cmd, None
with self._lock: if cmd is not None:
if not self._start_process(cmd, close_fds=True): with self._lock:
continue if not self._start_process(cmd, close_fds=True):
self._process.wait() continue
self._kill_children() if self._process:
self._process.wait()
self._kill_children()
+18 -15
View File
@@ -5,6 +5,7 @@ import subprocess
from patroni.exceptions import PostgresException from patroni.exceptions import PostgresException
from patroni.utils import polling_loop from patroni.utils import polling_loop
from threading import Lock from threading import Lock
from typing import Any, Dict, List, Optional, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,13 +16,13 @@ class CancellableExecutor(object):
There must be only one such process so that AsyncExecutor can easily cancel it. There must be only one such process so that AsyncExecutor can easily cancel it.
""" """
def __init__(self): def __init__(self) -> None:
self._process = None self._process = None
self._process_cmd = None self._process_cmd = None
self._process_children = [] self._process_children: List[psutil.Process] = []
self._lock = Lock() self._lock = Lock()
def _start_process(self, cmd, *args, **kwargs): def _start_process(self, cmd: List[str], *args: Any, **kwargs: Any) -> Optional[bool]:
"""This method must be executed only when the `_lock` is acquired""" """This method must be executed only when the `_lock` is acquired"""
try: try:
@@ -29,10 +30,10 @@ class CancellableExecutor(object):
self._process_cmd = cmd self._process_cmd = cmd
self._process = psutil.Popen(cmd, *args, **kwargs) self._process = psutil.Popen(cmd, *args, **kwargs)
except Exception: except Exception:
return logger.exception('Failed to execute %s', cmd) return logger.exception('Failed to execute %s', cmd)
return True return True
def _kill_process(self): def _kill_process(self) -> None:
with self._lock: with self._lock:
if self._process is not None and self._process.is_running() and not self._process_children: if self._process is not None and self._process.is_running() and not self._process_children:
try: try:
@@ -53,8 +54,8 @@ class CancellableExecutor(object):
except psutil.AccessDenied as e: except psutil.AccessDenied as e:
logger.warning('Failed to kill the process: %s', e.msg) logger.warning('Failed to kill the process: %s', e.msg)
def _kill_children(self): def _kill_children(self) -> None:
waitlist = [] waitlist: List[psutil.Process] = []
with self._lock: with self._lock:
for child in self._process_children: for child in self._process_children:
try: try:
@@ -69,15 +70,16 @@ class CancellableExecutor(object):
class CancellableSubprocess(CancellableExecutor): class CancellableSubprocess(CancellableExecutor):
def __init__(self): def __init__(self) -> None:
super(CancellableSubprocess, self).__init__() super(CancellableSubprocess, self).__init__()
self._is_cancelled = False self._is_cancelled = False
def call(self, *args, **kwargs): def call(self, *args: Any, **kwargs: Union[Any, Dict[str, str]]) -> Optional[int]:
for s in ('stdin', 'stdout', 'stderr'): for s in ('stdin', 'stdout', 'stderr'):
kwargs.pop(s, None) kwargs.pop(s, None)
communicate = kwargs.pop('communicate', None) communicate: Optional[Dict[str, str]] = kwargs.pop('communicate', None)
input_data = None
if isinstance(communicate, dict): if isinstance(communicate, dict):
input_data = communicate.get('input') input_data = communicate.get('input')
if input_data: if input_data:
@@ -96,25 +98,26 @@ class CancellableSubprocess(CancellableExecutor):
self._is_cancelled = False self._is_cancelled = False
started = self._start_process(*args, **kwargs) started = self._start_process(*args, **kwargs)
if started: if started and self._process is not None:
if isinstance(communicate, dict): if isinstance(communicate, dict):
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data) communicate['stdout'], communicate['stderr'] = \
self._process.communicate(input_data) # pyright: ignore [reportGeneralTypeIssues]
return self._process.wait() return self._process.wait()
finally: finally:
with self._lock: with self._lock:
self._process = None self._process = None
self._kill_children() self._kill_children()
def reset_is_cancelled(self): def reset_is_cancelled(self) -> None:
with self._lock: with self._lock:
self._is_cancelled = False self._is_cancelled = False
@property @property
def is_cancelled(self): def is_cancelled(self) -> bool:
with self._lock: with self._lock:
return self._is_cancelled return self._is_cancelled
def cancel(self, kill=False): def cancel(self, kill: bool = False) -> None:
with self._lock: with self._lock:
self._is_cancelled = True self._is_cancelled = True
if self._process is None or not self._process.is_running(): if self._process is None or not self._process.is_running():
File diff suppressed because it is too large Load Diff
+131 -17
View File
@@ -2,44 +2,158 @@ import logging
from contextlib import contextmanager from contextlib import contextmanager
from threading import Lock from threading import Lock
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from psycopg import Connection, Cursor
from psycopg2 import connection, cursor
from .. import psycopg from .. import psycopg
from ..exceptions import PostgresConnectionException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Connection(object): class NamedConnection:
"""Helper class to manage ``psycopg`` connections from Patroni to PostgreSQL.
def __init__(self): :ivar server_version: PostgreSQL version in integer format where we are connected to.
self._lock = Lock() """
server_version: int
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._connection = None
self._cursor_holder = None
def set_conn_kwargs(self, conn_kwargs): @property
self._conn_kwargs = conn_kwargs 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): 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: with self._lock:
if not self._connection or self._connection.closed != 0: 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._connection = psycopg.connect(**self._conn_kwargs)
self.server_version = self._connection.server_version self.server_version = getattr(self._connection, 'server_version', 0)
return self._connection return self._connection
def cursor(self): def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0: """Execute a query with parameters and optionally returns a response.
logger.info("establishing a new patroni connection to the postgres cluster")
self._cursor_holder = self.get().cursor()
return self._cursor_holder
def close(self): :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: if self._connection and self._connection.closed == 0:
self._connection.close() self._connection.close()
logger.info("closed patroni connection to the postgresql cluster") if not silent:
self._cursor_holder = self._connection = None 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 @contextmanager
def get_connection_cursor(**kwargs): def get_connection_cursor(**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
conn = psycopg.connect(**kwargs) conn = psycopg.connect(**kwargs)
with conn.cursor() as cur: with conn.cursor() as cur:
yield cur yield cur
+8 -8
View File
@@ -2,7 +2,9 @@ import errno
import logging import logging
import os import os
from patroni.exceptions import PostgresException from typing import Iterable, Tuple
from ..exceptions import PostgresException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,29 +57,27 @@ def postgres_major_version_to_int(pg_version: str) -> int:
return postgres_version_to_int(pg_version + '.0') return postgres_version_to_int(pg_version + '.0')
def parse_lsn(lsn): def parse_lsn(lsn: str) -> int:
t = lsn.split('/') t = lsn.split('/')
return int(t[0], 16) * 0x100000000 + int(t[1], 16) return int(t[0], 16) * 0x100000000 + int(t[1], 16)
def parse_history(data): def parse_history(data: str) -> Iterable[Tuple[int, int, str]]:
for line in data.split('\n'): for line in data.split('\n'):
values = line.strip().split('\t') values = line.strip().split('\t')
if len(values) == 3: if len(values) == 3:
try: try:
values[0] = int(values[0]) yield int(values[0]), parse_lsn(values[1]), values[2]
values[1] = parse_lsn(values[1])
yield values
except (IndexError, ValueError): except (IndexError, ValueError):
logger.exception('Exception when parsing timeline history line "%s"', values) logger.exception('Exception when parsing timeline history line "%s"', values)
def format_lsn(lsn, full=False): def format_lsn(lsn: int, full: bool = False) -> str:
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}' template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
return template.format(lsn >> 32, lsn & 0xFFFFFFFF) return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
def fsync_dir(path): def fsync_dir(path: str) -> None:
if os.name != 'nt': if os.name != 'nt':
fd = os.open(path, os.O_DIRECTORY) fd = os.open(path, os.O_DIRECTORY)
try: try:
+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()
@@ -4,11 +4,17 @@ import time
from threading import Condition, Event, Thread from threading import Condition, Event, Thread
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from .connection import Connection from . import AbstractMPP, AbstractMPPHandler
from ..dcs import CITUS_COORDINATOR_GROUP_ID from ...dcs import Cluster
from ..psycopg import connect, quote_ident 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}$') CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,7 +22,8 @@ logger = logging.getLogger(__name__)
class PgDistNode(object): class PgDistNode(object):
"""Represents a single row in the `pg_dist_node` table""" """Represents a single row in the `pg_dist_node` table"""
def __init__(self, group, host, port, event, nodeid=None, timeout=None, cooldown=None): def __init__(self, group: int, host: str, port: int, event: str, nodeid: Optional[int] = None,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
self.group = group self.group = group
# A weird way of pausing client connections by adding the `-demoted` suffix to the hostname # A weird way of pausing client connections by adding the `-demoted` suffix to the hostname
self.host = host + ('-demoted' if event == 'before_demote' else '') self.host = host + ('-demoted' if event == 'before_demote' else '')
@@ -29,7 +36,7 @@ class PgDistNode(object):
# If transaction was started, we need to COMMIT/ROLLBACK before the deadline # If transaction was started, we need to COMMIT/ROLLBACK before the deadline
self.timeout = timeout self.timeout = timeout
self.cooldown = cooldown or 10000 # 10s by default self.cooldown = cooldown or 10000 # 10s by default
self.deadline = 0 self.deadline: float = 0
# All changes in the pg_dist_node are serialized on the Patroni # All changes in the pg_dist_node are serialized on the Patroni
# side by performing them from a thread. The thread, that is # side by performing them from a thread. The thread, that is
@@ -38,83 +45,105 @@ class PgDistNode(object):
# the worker, and once it is done notify the calling thread. # the worker, and once it is done notify the calling thread.
self._event = Event() self._event = Event()
def wait(self): def wait(self) -> None:
self._event.wait() self._event.wait()
def wakeup(self): def wakeup(self) -> None:
self._event.set() self._event.set()
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return isinstance(other, PgDistNode) and self.event == other.event\ return isinstance(other, PgDistNode) and self.event == other.event\
and self.host == other.host and self.port == other.port and self.host == other.host and self.port == other.port
def __ne__(self, other): def __ne__(self, other: Any) -> bool:
return not self == other return not self == other
def __str__(self): def __str__(self) -> str:
return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})' return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})'
.format(self.nodeid, self.group, self.host, self.port, self.event)) .format(self.nodeid, self.group, self.host, self.port, self.event))
def __repr__(self): def __repr__(self) -> str:
return str(self) return str(self)
class CitusHandler(Thread): class Citus(AbstractMPP):
def __init__(self, postgresql, config): group_re = re.compile('^(0|[1-9][0-9]*)$')
super(CitusHandler, self).__init__()
@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 self.daemon = True
self._postgresql = postgresql if config:
self._config = config self._connection = postgresql.connection_pool.get(
self._connection = Connection() 'citus', {'dbname': config['database'],
self._pg_dist_node = {} # Cache of pg_dist_node: {groupid: PgDistNode()} 'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
self._tasks = [] # Requests to change pg_dist_node, every task is a `PgDistNode` self._pg_dist_node: Dict[int, PgDistNode] = {} # Cache of pg_dist_node: {groupid: PgDistNode()}
self._condition = Condition() # protects _pg_dist_node, _tasks, and _schedule_load_pg_dist_node self._tasks: List[PgDistNode] = [] # Requests to change pg_dist_node, every task is a `PgDistNode`
self._in_flight = None # Reference to the `PgDistNode` if there is a transaction in progress changing it 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() self.schedule_cache_rebuild()
def is_enabled(self): def schedule_cache_rebuild(self) -> None:
return isinstance(self._config, dict) """Cache rebuild handler.
def group(self): Is called to notify handler that it has to refresh its metadata cache from the database.
return self._config['group'] """
def is_coordinator(self):
return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID
def is_worker(self):
return self.is_enabled() and not self.is_coordinator()
def set_conn_kwargs(self, kwargs):
if 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):
with self._condition: with self._condition:
self._schedule_load_pg_dist_node = True self._schedule_load_pg_dist_node = True
def on_demote(self): def on_demote(self) -> None:
with self._condition: with self._condition:
self._pg_dist_node.clear() self._pg_dist_node.clear()
self._tasks[:] = [] empty_tasks: List[PgDistNode] = []
self._tasks[:] = empty_tasks
self._in_flight = None self._in_flight = None
def query(self, sql, *params): def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
try: try:
logger.debug('query(%s, %s)', sql, params) logger.debug('query(%s, %s)', sql, params)
cursor = self._connection.cursor() return self._connection.query(sql, *params)
cursor.execute(sql, params or None)
return cursor
except Exception as e: except Exception as e:
logger.error('Exception when executing query "%s", (%s): %r', sql, params, e) logger.error('Exception when executing query "%s", (%s): %r', sql, params, e)
self._connection.close() self._connection.close()
self._in_flight = None with self._condition:
self._in_flight = None
self.schedule_cache_rebuild() self.schedule_cache_rebuild()
raise e raise e
def load_pg_dist_node(self): def load_pg_dist_node(self) -> bool:
"""Read from the `pg_dist_node` table and put it into the local cache""" """Read from the `pg_dist_node` table and put it into the local cache"""
with self._condition: with self._condition:
@@ -123,17 +152,17 @@ class CitusHandler(Thread):
self._schedule_load_pg_dist_node = False self._schedule_load_pg_dist_node = False
try: try:
cursor = self.query("SELECT nodeid, groupid, nodename, nodeport, noderole" rows = self.query("SELECT nodeid, groupid, nodename, nodeport, noderole"
" FROM pg_catalog.pg_dist_node WHERE noderole = 'primary'") " FROM pg_catalog.pg_dist_node WHERE noderole = 'primary'")
except Exception: except Exception:
return False return False
with self._condition: with self._condition:
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in cursor} self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows}
return True return True
def sync_pg_dist_node(self, cluster): def sync_meta_data(self, cluster: Cluster) -> None:
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop. """Maintain the ``pg_dist_node`` from the coordinator leader every heartbeat loop.
We can't always rely on REST API calls from worker nodes in order We can't always rely on REST API calls from worker nodes in order
to maintain `pg_dist_node`, therefore at least once per heartbeat to maintain `pg_dist_node`, therefore at least once per heartbeat
@@ -156,20 +185,22 @@ class CitusHandler(Thread):
and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running': and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running':
self.add_task('after_promote', group, leader.conn_url) self.add_task('after_promote', group, leader.conn_url)
def find_task_by_group(self, group): def find_task_by_group(self, group: int) -> Optional[int]:
for i, task in enumerate(self._tasks): for i, task in enumerate(self._tasks):
if task.group == group: if task.group == group:
return i return i
def pick_task(self): def pick_task(self) -> Tuple[Optional[int], Optional[PgDistNode]]:
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list """Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
Tasks are picked by following priorities: Tasks are picked by following priorities:
1. If there is already a transaction in progress, pick a task 1. If there is already a transaction in progress, pick a task
that that will change already affected worker primary. that that will change already affected worker primary.
2. If the coordinator address should be changed - pick a task 2. If the coordinator address should be changed - pick a task
with group=0 (coordinators are always in group 0). with group=0 (coordinators are always in group 0).
3. Pick a task that is the oldest (first from the self._tasks)""" 3. Pick a task that is the oldest (first from the self._tasks)
"""
with self._condition: with self._condition:
if self._in_flight: if self._in_flight:
@@ -195,28 +226,31 @@ class CitusHandler(Thread):
task.nodeid = self._pg_dist_node[task.group].nodeid task.nodeid = self._pg_dist_node[task.group].nodeid
return i, task return i, task
def update_node(self, task): def update_node(self, task: PgDistNode) -> None:
if task.nodeid is not None: if task.nodeid is not None:
self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)', self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)',
task.nodeid, task.host, task.port, task.cooldown) task.nodeid, task.host, task.port, task.cooldown)
elif task.event != 'before_demote': elif task.event != 'before_demote':
task.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')", task.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')",
task.host, task.port, task.group).fetchone()[0] task.host, task.port, task.group)[0][0]
def process_task(self, task): def process_task(self, task: PgDistNode) -> bool:
"""Updates a single row in `pg_dist_node` table, optionally in a transaction. """Updates a single row in `pg_dist_node` table, optionally in a transaction.
The transaction is started if we do a demote of the worker node The transaction is started if we do a demote of the worker node or before promoting the other worker if
or before promoting the other worker if there is not transaction there is no transaction in progress. And, the transaction is committed when the switchover/failover completed.
in progress. And, the transaction it is committed when the
switchover/failover completed.
This method returns `True` if node was updated (optionally, .. note:
transaction was committed) as an indicator that The maximum lifetime of the transaction in progress is controlled outside of this method.
the `self._pg_dist_node` cache should be updated.
The maximum lifetime of the transaction in progress .. note:
is controlled outside of this method.""" Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread.
:param task: reference to a :class:`PgDistNode` object that represents a row to be updated/created.
:returns: `True` if the row was succesfully created/updated or transaction in progress
was committed as an indicator that the `self._pg_dist_node` cache should be updated,
or, if the new transaction was opened, this method returns `False`.
"""
if task.event == 'after_promote': if task.event == 'after_promote':
# The after_promote may happen without previous before_demote and/or # The after_promote may happen without previous before_demote and/or
@@ -227,7 +261,6 @@ class CitusHandler(Thread):
self.update_node(task) self.update_node(task)
if self._in_flight: if self._in_flight:
self.query('COMMIT') self.query('COMMIT')
self._in_flight = None
return True return True
else: # before_demote, before_promote else: # before_demote, before_promote
if task.timeout: if task.timeout:
@@ -235,31 +268,37 @@ class CitusHandler(Thread):
if not self._in_flight: if not self._in_flight:
self.query('BEGIN') self.query('BEGIN')
self.update_node(task) self.update_node(task)
self._in_flight = task
return False return False
def process_tasks(self): def process_tasks(self) -> None:
while True: while True:
# Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread.
if not self._in_flight and not self.load_pg_dist_node(): if not self._in_flight and not self.load_pg_dist_node():
break break
i, task = self.pick_task() i, task = self.pick_task()
if not task: if not task or i is None:
break break
try: try:
update_cache = self.process_task(task) update_cache = self.process_task(task)
except Exception as e: except Exception as e:
logger.error('Exception when working with pg_dist_node: %r', e) logger.error('Exception when working with pg_dist_node: %r', e)
update_cache = False update_cache = None
with self._condition: with self._condition:
if self._tasks: if self._tasks:
if update_cache: if update_cache:
self._pg_dist_node[task.group] = task self._pg_dist_node[task.group] = task
if update_cache is False: # an indicator that process_tasks has started a transaction
self._in_flight = task
else:
self._in_flight = None
if id(self._tasks[i]) == id(task): if id(self._tasks[i]) == id(task):
self._tasks.pop(i) self._tasks.pop(i)
task.wakeup() task.wakeup()
def run(self): def run(self) -> None:
while True: while True:
try: try:
with self._condition: with self._condition:
@@ -280,15 +319,23 @@ class CitusHandler(Thread):
except Exception: except Exception:
logger.exception('run') logger.exception('run')
def _add_task(self, task): def _add_task(self, task: PgDistNode) -> bool:
with self._condition: with self._condition:
i = self.find_task_by_group(task.group) i = self.find_task_by_group(task.group)
# task.timeout is None is an indicator that it was scheduled # The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
# from the sync_pg_dist_node() and we don't want to override if task.timeout is None:
# already existing task created from REST API. # We don't want to override the already existing task created from REST API.
if task.timeout is None and (i is not None or self._in_flight and self._in_flight.group == task.group): if i is not None and self._tasks[i].timeout is not None:
return False return False
# There is a little race condition with tasks created from REST API - the call made "before" the member
# key is updated in DCS. Therefore it is possible that :func:`sync_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.group == task.group and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time():
return False
# Override already existing task for the same worker group # Override already existing task for the same worker group
if i is not None: if i is not None:
@@ -306,41 +353,50 @@ class CitusHandler(Thread):
return True return True
return False return False
def add_task(self, event, group, conn_url, timeout=None, cooldown=None): def add_task(self, event: str, group: int, conn_url: str,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistNode]:
try: try:
r = urlparse(conn_url) r = urlparse(conn_url)
except Exception as e: except Exception as e:
return logger.error('Failed to parse connection url %s: %r', conn_url, e) return logger.error('Failed to parse connection url %s: %r', conn_url, e)
host = r.hostname host = r.hostname
port = r.port or 5432 if host:
task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown) port = r.port or 5432
return task if self._add_task(task) else None task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown)
return task if self._add_task(task) else None
def handle_event(self, cluster, event): def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
if not self.is_alive(): if not self.is_alive():
return return
cluster = cluster.workers.get(event['group']) worker = cluster.workers.get(event['group'])
if not (cluster and cluster.leader and cluster.leader.name == event['leader'] and cluster.leader.conn_url): if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url):
return return
task = self.add_task(event['type'], event['group'], task = self.add_task(event['type'], event['group'],
cluster.leader.conn_url, worker.leader.conn_url,
event['timeout'], event['cooldown']*1000) event['timeout'], event['cooldown'] * 1000)
if task and event['type'] == 'before_demote': if task and event['type'] == 'before_demote':
task.wait() task.wait()
def bootstrap(self): def bootstrap(self) -> None:
if not self.is_enabled(): """Bootstrap handler.
return
conn_kwargs = self._postgresql.config.local_connect_kwargs Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
conn_kwargs['options'] = '-c synchronous_commit=local -c statement_timeout=0' """
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
'options': '-c synchronous_commit=local -c statement_timeout=0'}
if self._config['database'] != self._postgresql.database: if self._config['database'] != self._postgresql.database:
conn = connect(**conn_kwargs) conn = connect(**conn_kwargs)
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(quote_ident(self._config['database'], conn))) cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
except ProgrammingError as exc:
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
logger.debug('Exception when creating database: %r', exc)
else:
raise exc
finally: finally:
conn.close() conn.close()
@@ -348,7 +404,7 @@ class CitusHandler(Thread):
conn = connect(**conn_kwargs) conn = connect(**conn_kwargs)
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('CREATE EXTENSION citus') cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
superuser = self._postgresql.config.superuser superuser = self._postgresql.config.superuser
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser} params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
@@ -364,10 +420,11 @@ class CitusHandler(Thread):
finally: finally:
conn.close() conn.close()
def adjust_postgres_gucs(self, parameters): def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
if not self.is_enabled(): """Adjust GUCs in the current PostgreSQL configuration.
return
: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 # citus extension must be on the first place in shared_preload_libraries
shared_preload_libraries = list(filter( shared_preload_libraries = list(filter(
lambda el: el and el != 'citus', lambda el: el and el != 'citus',
@@ -375,15 +432,28 @@ class CitusHandler(Thread):
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries) parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2 # if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
if parameters.get('max_prepared_transactions') == 0: if parameters['max_prepared_transactions'] == 0:
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2 parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
# Resharding in Citus implemented using logical replication # Resharding in Citus implemented using logical replication
parameters['wal_level'] = 'logical' parameters['wal_level'] = 'logical'
def ignore_replication_slot(self, slot): # Sometimes Citus needs to connect to the local postgres. We will do it the same way as Patroni does.
if self.is_enabled() and self._postgresql.is_leader() and\ parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
slot['type'] == 'logical' and slot['database'] == self._config['database']:
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']) m = CITUS_SLOT_NAME_RE.match(slot['name'])
return m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'] return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
return False return False
+22 -18
View File
@@ -7,6 +7,9 @@ import signal
import subprocess import subprocess
import sys import sys
from multiprocessing.connection import Connection
from typing import Dict, Optional, List
from patroni import PATRONI_ENV_PREFIX, KUBERNETES_ENV_PREFIX from patroni import PATRONI_ENV_PREFIX, KUBERNETES_ENV_PREFIX
# avoid spawning the resource tracker process # avoid spawning the resource tracker process
@@ -26,7 +29,7 @@ STOP_SIGNALS = {
} }
def pg_ctl_start(conn, cmdline, env): def pg_ctl_start(conn: Connection, cmdline: List[str], env: Dict[str, str]) -> None:
if os.name != 'nt': if os.name != 'nt':
os.setsid() os.setsid()
try: try:
@@ -40,7 +43,8 @@ def pg_ctl_start(conn, cmdline, env):
class PostmasterProcess(psutil.Process): class PostmasterProcess(psutil.Process):
def __init__(self, pid): def __init__(self, pid: int) -> None:
self._postmaster_pid: Dict[str, str]
self.is_single_user = False self.is_single_user = False
if pid < 0: if pid < 0:
pid = -pid pid = -pid
@@ -48,7 +52,7 @@ class PostmasterProcess(psutil.Process):
super(PostmasterProcess, self).__init__(pid) super(PostmasterProcess, self).__init__(pid)
@staticmethod @staticmethod
def _read_postmaster_pidfile(data_dir): def _read_postmaster_pidfile(data_dir: str) -> Dict[str, str]:
"""Reads and parses postmaster.pid from the data directory """Reads and parses postmaster.pid from the data directory
:returns dictionary of values if successful, empty dictionary otherwise :returns dictionary of values if successful, empty dictionary otherwise
@@ -60,7 +64,7 @@ class PostmasterProcess(psutil.Process):
except IOError: except IOError:
return {} return {}
def _is_postmaster_process(self): def _is_postmaster_process(self) -> bool:
try: try:
start_time = int(self._postmaster_pid.get('start_time', 0)) start_time = int(self._postmaster_pid.get('start_time', 0))
if start_time and abs(self.create_time() - start_time) > 3: if start_time and abs(self.create_time() - start_time) > 3:
@@ -79,7 +83,7 @@ class PostmasterProcess(psutil.Process):
return True return True
@classmethod @classmethod
def _from_pidfile(cls, data_dir): def _from_pidfile(cls, data_dir: str) -> Optional['PostmasterProcess']:
postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir) postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir)
try: try:
pid = int(postmaster_pid.get('pid', 0)) pid = int(postmaster_pid.get('pid', 0))
@@ -88,10 +92,10 @@ class PostmasterProcess(psutil.Process):
proc._postmaster_pid = postmaster_pid proc._postmaster_pid = postmaster_pid
return proc return proc
except ValueError: except ValueError:
pass return None
@staticmethod @staticmethod
def from_pidfile(data_dir): def from_pidfile(data_dir: str) -> Optional['PostmasterProcess']:
try: try:
proc = PostmasterProcess._from_pidfile(data_dir) proc = PostmasterProcess._from_pidfile(data_dir)
return proc if proc and proc._is_postmaster_process() else None return proc if proc and proc._is_postmaster_process() else None
@@ -99,13 +103,13 @@ class PostmasterProcess(psutil.Process):
return None return None
@classmethod @classmethod
def from_pid(cls, pid): def from_pid(cls, pid: int) -> Optional['PostmasterProcess']:
try: try:
return cls(pid) return cls(pid)
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
return None return None
def signal_kill(self): def signal_kill(self) -> bool:
"""to suspend and kill postmaster and all children """to suspend and kill postmaster and all children
:returns True if postmaster and children are killed, False if error :returns True if postmaster and children are killed, False if error
@@ -141,7 +145,7 @@ class PostmasterProcess(psutil.Process):
psutil.wait_procs(children + [self]) psutil.wait_procs(children + [self])
return True return True
def signal_stop(self, mode, pg_ctl='pg_ctl'): def signal_stop(self, mode: str, pg_ctl: str = 'pg_ctl') -> Optional[bool]:
"""Signal postmaster process to stop """Signal postmaster process to stop
:returns None if signaled, True if process is already gone, False if error :returns None if signaled, True if process is already gone, False if error
@@ -161,7 +165,7 @@ class PostmasterProcess(psutil.Process):
return None return None
def pg_ctl_kill(self, mode, pg_ctl): def pg_ctl_kill(self, mode: str, pg_ctl: str) -> Optional[bool]:
try: try:
status = subprocess.call([pg_ctl, "kill", STOP_SIGNALS[mode], str(self.pid)]) status = subprocess.call([pg_ctl, "kill", STOP_SIGNALS[mode], str(self.pid)])
except OSError: except OSError:
@@ -171,8 +175,8 @@ class PostmasterProcess(psutil.Process):
else: else:
return not self.is_running() return not self.is_running()
def wait_for_user_backends_to_close(self, stop_timeout): def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15 # These regexps are cross checked against versions PostgreSQL 9.1 .. 16
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|" aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|" "checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
"walreceiver|wal sender process|walsender|walwriter|background writer|" "walreceiver|wal sender process|walsender|walwriter|background writer|"
@@ -183,8 +187,8 @@ class PostmasterProcess(psutil.Process):
except psutil.Error: except psutil.Error:
return logger.debug('Failed to get list of postmaster children') return logger.debug('Failed to get list of postmaster children')
user_backends = [] user_backends: List[psutil.Process] = []
user_backends_cmdlines = {} user_backends_cmdlines: Dict[int, str] = {}
for child in children: for child in children:
try: try:
cmdline = child.cmdline() cmdline = child.cmdline()
@@ -195,7 +199,7 @@ class PostmasterProcess(psutil.Process):
pass pass
if user_backends: if user_backends:
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values())) logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values()))
gone, live = psutil.wait_procs(user_backends, stop_timeout) _, live = psutil.wait_procs(user_backends, stop_timeout)
if stop_timeout and live: if stop_timeout and live:
live = [user_backends_cmdlines[b.pid] for b in live] live = [user_backends_cmdlines[b.pid] for b in live]
logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live)) logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live))
@@ -203,7 +207,7 @@ class PostmasterProcess(psutil.Process):
logger.debug("Backends closed") logger.debug("Backends closed")
@staticmethod @staticmethod
def start(pgcommand, data_dir, conf, options): def start(pgcommand: str, data_dir: str, conf: str, options: List[str]) -> Optional['PostmasterProcess']:
# Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information # Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information
# it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start # it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start
# in python. It will start postgres, wait for port to be open and wait until postgres will start # in python. It will start postgres, wait for port to be open and wait until postgres will start
@@ -234,7 +238,7 @@ class PostmasterProcess(psutil.Process):
pass pass
cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options
logger.debug("Starting postgres: %s", " ".join(cmdline)) logger.debug("Starting postgres: %s", " ".join(cmdline))
ctx = multiprocessing.get_context('spawn') if sys.version_info >= (3, 4) else multiprocessing ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe(False) parent_conn, child_conn = ctx.Pipe(False)
proc = ctx.Process(target=pg_ctl_start, args=(child_conn, cmdline, env)) proc = ctx.Process(target=pg_ctl_start, args=(child_conn, cmdline, env))
proc.start() proc.start()

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