Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries

This commit is contained in:
Alexander Kukushkin
2023-08-02 10:12:33 +02:00
48 changed files with 951 additions and 340 deletions
+2 -3
View File
@@ -25,8 +25,7 @@ RUN set -ex \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping --fix-missing \
&& pip3 install dumb-init \
net-tools iputils-ping dumb-init --fix-missing \
\
# Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
@@ -71,7 +70,7 @@ RUN set -ex \
# Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
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 clean -y \
+2 -3
View File
@@ -25,7 +25,7 @@ RUN set -ex \
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
| xargs apt-get install -y vim curl less jq locales haproxy sudo \
python3-etcd python3-kazoo python3-pip busybox \
net-tools iputils-ping lsb-release --fix-missing \
net-tools iputils-ping lsb-release dumb-init --fix-missing \
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
apt-get install -y postgresql-server-dev-$PG_MAJOR \
git gcc make autoconf \
@@ -42,7 +42,6 @@ RUN set -ex \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
fi \
&& pip3 install dumb-init \
\
# Cleanup all locales but en_US.UTF-8
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
@@ -88,7 +87,7 @@ RUN set -ex \
# Clean up all useless packages and some files
&& apt-get purge -y --allow-remove-essential python3-pip gzip bzip2 util-linux e2fsprogs \
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 \
+19 -6
View File
@@ -112,7 +112,11 @@ Kubernetes
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`.
- **PATRONI\_KUBERNETES\_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\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`.
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **PATRONI\_KUBERNETES\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `master`. Default value is `master`.
- **PATRONI\_KUBERNETES\_FOLLOWER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is `replica`. Default value is `replica`.
- **PATRONI\_KUBERNETES\_STANDBY\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is ``standby-leader``. Default value is ``standby-leader``.
- **PATRONI\_KUBERNETES\_TMP\_ROLE\_LABEL**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **PATRONI\_KUBERNETES\_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.
@@ -196,10 +200,19 @@ REST API
- **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
---
- **PATRONICTL\_CONFIG\_FILE**: location of the configuration file.
- **PATRONI\_CTL\_INSECURE**: Allow connections to REST API without verifying SSL certs.
- **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\_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\_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.
- **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_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.
+2 -2
View File
@@ -46,9 +46,9 @@ In order to change the dynamic configuration you can use either ``patronictl edi
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **my\_slot\_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni.
- **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.
+49
View File
@@ -32,6 +32,55 @@ Configuration
Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation.
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
--------
+18 -4
View File
@@ -155,7 +155,11 @@ Kubernetes
- **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``.
- **role\_label**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
- **leader\_label\_value**: (optional) value of the pod label when Postgres role is ``master``. Default value is ``master``.
- **follower\_label\_value**: (optional) value of the pod label when Postgres role is ``replica``. Default value is ``replica``.
- **standby\_leader\_label\_value**: (optional) value of the pod label when Postgres role is ``standby-leader``. Default value is ``standby-leader``.
- **tmp_\role\_label**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
@@ -335,17 +339,27 @@ Here is an example of both **http_extra_headers** and **https_extra_headers**:
https_extra_headers:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
.. warning::
- The ``restapi.connect_address`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
- If you enabled client certificates validation (``restapi.verify_client`` is set to ``required``), you also **must** provide **valid client certificates** in the ``ctl.certfile``, ``ctl.keyfile``, ``ctl.keyfile_password``. If not provided, Patroni will not work correctly.
.. _patronictl_settings:
CTL
---
- **ctl**: (optional)
- **authentication**:
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format. 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.
- **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
--------
+1
View File
@@ -79,6 +79,7 @@ Feature: basic replication
When I add the table buz to postgres2
Then table buz is present on postgres0 after 20 seconds
@reject-duplicate-name
Scenario: check graceful rejection when two nodes have the same name
Given I start duplicate postgres0 on port 8011
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
+6 -2
View File
@@ -59,7 +59,7 @@ class AbstractController(abc.ABC):
break
time.sleep(1)
else:
assert False,\
assert False, \
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
def stop(self, kill=False, timeout=15, _=False):
@@ -1082,7 +1082,9 @@ def before_all(context):
'PATRONI_RESTAPI_CERTFILE': context.certfile,
'PATRONI_RESTAPI_KEYFILE': context.keyfile,
'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})
context.request_executor = PatroniRequest({'ctl': ctl}, True)
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
@@ -1144,3 +1146,5 @@ def before_scenario(context, scenario):
break
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()))
if 'reject-duplicate-name' in scenario.effective_tags and context.dcs_ctl.name() == 'raft':
scenario.skip('Flaky test with Raft')
+4 -4
View File
@@ -21,7 +21,7 @@ def start_duplicate_patroni(context, name, port):
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),\
assert 'is not running after being started' in str(e), \
"No error was raised by duplicate start of {0} ".format(name)
@@ -88,14 +88,14 @@ def table_is_present_on(context, table_name, pg_name, max_replication_delay):
break
sleep(1)
else:
assert False,\
assert False, \
"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')
def check_role(context, pg_name, pg_role, max_promotion_timeout):
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)
@@ -111,5 +111,5 @@ def replication_works(context, primary, replica, time_limit):
@then('there is a "{message}" {level:w} in the {node} patroni log')
def check_patroni_log(context, message, level, node):
messsages_of_level = context.pctl.read_patroni_log(node, level)
assert any(message in line for line in messsages_of_level),\
assert any(message in line for line in messsages_of_level), \
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
+1 -1
View File
@@ -125,5 +125,5 @@ def check_transaction(context, name):
@step("a transaction finishes in {timeout:d} seconds")
def check_transaction_timeout(context, timeout):
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout,\
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \
"a transaction finished earlier than in {0} seconds".format(timeout)
+2 -2
View File
@@ -98,7 +98,7 @@ def do_run(context, cmd):
@then('I receive a response {component:w} {data}')
def check_response(context, component, data):
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)
elif component == 'returncode':
assert context.status_code == int(data), "return code {0} != {1}, {2}".format(context.status_code,
@@ -158,7 +158,7 @@ def check_http_response(context, url, value, timeout, negate=False):
break
time.sleep(1)
else:
assert False,\
assert False, \
"Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout)
+6
View File
@@ -463,6 +463,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
* ``patroni_dcs_last_seen``: epoch timestamp when DCS was last contacted successfully;
* ``patroni_pending_restart``: ``1`` if this PostgreSQL node is pending a restart, else ``0``;
* ``patroni_is_paused``: ``1`` if Patroni is in maintenance node, else ``0``.
For PostgreSQL v9.6+ the response will also have the following:
* ``patroni_postgres_streaming``: 1 if Postgres is streaming from another node, else ``0``;
* ``patroni_postgres_in_archive_recovery``: ``1`` if Postgres isn't streaming and
there is ``restore_command`` available, else ``0``.
"""
postgres = self.get_postgresql_status(True)
patroni = self.server.patroni
+23 -10
View File
@@ -329,9 +329,19 @@ class Config(object):
@staticmethod
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()
if name not in ConfigHandler.CMDLINE_OPTIONS
or not is_local and ConfigHandler.CMDLINE_OPTIONS[name][1](value)}
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:
if ConfigHandler.CMDLINE_OPTIONS[name][1](value):
pg_params[name] = value
else:
logging.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
return pg_params
def _safe_copy_dynamic_configuration(self, dynamic_configuration: Dict[str, Any]) -> Dict[str, Any]:
config = deepcopy(self.__DEFAULT_CONFIG)
@@ -452,9 +462,10 @@ class Config(object):
ret[param] = value
return ret
restapi_auth = _get_auth('restapi')
if restapi_auth:
ret['restapi']['authentication'] = restapi_auth
for section in ('ctl', 'restapi'):
auth = _get_auth(section)
if auth:
ret[section]['authentication'] = auth
authentication = {}
for user_type in ('replication', 'superuser', 'rewind'):
@@ -474,7 +485,8 @@ class Config(object):
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE') and name:
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
value = os.environ.pop(param)
if name == 'CITUS':
if suffix == 'GROUP':
@@ -531,9 +543,10 @@ class Config(object):
elif name not in config or name in ['watchdog']:
config[name] = deepcopy(value) if value else {}
# restapi server expects to get restapi.auth = 'username:password'
if 'restapi' in config and 'authentication' in config['restapi']:
config['restapi']['auth'] = '{username}:{password}'.format(**config['restapi']['authentication'])
# restapi server expects to get restapi.auth = 'username:password' and similarly for `ctl`
for section in ('ctl', 'restapi'):
if section in config and 'authentication' in config[section]:
config[section]['auth'] = '{username}:{password}'.format(**config[section]['authentication'])
# special treatment for old config
+2 -3
View File
@@ -254,7 +254,6 @@ arg_cluster_name = click.argument('cluster_name', required=False,
option_default_citus_group = click.option('--group', required=False, type=int, help='Citus group',
default=lambda: click.get_current_context().obj.get('citus', {}).get('group'))
option_citus_group = click.option('--group', required=False, type=int, help='Citus group')
option_insecure = click.option('-k', '--insecure', is_flag=True, help='Allow connections to SSL sites without certs')
role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 'standby', 'any', 'master'])
@@ -262,7 +261,7 @@ role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 's
@click.option('--config-file', '-c', help='Configuration file',
envvar='PATRONICTL_CONFIG_FILE', default=CONFIG_FILE_PATH)
@click.option('--dcs-url', '--dcs', '-d', 'dcs_url', help='The DCS connect url', envvar='DCS_URL')
@option_insecure
@click.option('-k', '--insecure', is_flag=True, help='Allow connections to SSL sites without certs')
@click.pass_context
def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure: bool) -> None:
"""Entry point of ``patronictl`` utility.
@@ -1544,7 +1543,7 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str,
logging.debug(member)
lag = member.get('lag', '')
member.update(c=name, member=member['name'], group=g,
member.update(cluster=name, member=member['name'], group=g,
host=member.get('host', ''), tl=member.get('timeline', ''),
role=member['role'].replace('_', ' ').title(),
lag_in_mb=round(lag / 1024 / 1024) if isinstance(lag, int) else lag,
+222 -80
View File
@@ -15,7 +15,9 @@ from collections import defaultdict
from copy import deepcopy
from random import randint
from threading import Event, Lock
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Set, Tuple, Union, TYPE_CHECKING
from types import ModuleType
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Set, Tuple, Union, TYPE_CHECKING, \
Type, Iterator
from urllib.parse import urlparse, urlunparse, parse_qsl
from ..exceptions import PatroniFatalException
@@ -85,38 +87,83 @@ def dcs_modules() -> List[str]:
return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg]
def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
modules = dcs_modules()
def iter_dcs_classes(
config: Optional[Union['Config', Dict[str, Any]]] = None
) -> Iterator[Tuple[str, Type['AbstractDCS']]]:
"""Attempt to import DCS modules that are present in the given configuration.
.. note::
If a module successfully imports we can assume that all its requirements are installed.
:param config: configuration information with possible DCS names as keys. If given, only attempt to import DCS
modules defined in the configuration. Else, if ``None``, attempt to import any supported DCS module.
:yields: a tuple containing the module ``name`` and the imported DCS class object.
"""
for mod_name in dcs_modules():
name = mod_name.rpartition('.')[2]
if config is None or name in config:
for module_name in modules:
name = module_name.split('.')[-1]
if name in config: # we will try to import only modules which have configuration section in the config file
try:
module = importlib.import_module(module_name)
for key, item in module.__dict__.items(): # iterate through the module content
# try to find implementation of AbstractDCS interface, class name must match with module_name
if key.lower() == name and inspect.isclass(item) and issubclass(item, AbstractDCS):
# propagate some parameters
config[name].update({p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
'patronictl', 'ttl', 'retry_timeout') if p in config})
# From citus section we only need "group" parameter, but will propagate everything just in case.
if isinstance(config.get('citus'), dict):
config[name].update(config['citus'])
return item(config[name])
except ImportError:
logger.debug('Failed to import %s', module_name)
module = importlib.import_module(mod_name)
dcs_module = find_dcs_class_in_module(module)
if dcs_module:
yield name, dcs_module
available_implementations: List[str] = []
for module_name in modules:
name = module_name.split('.')[-1]
try:
module = importlib.import_module(module_name)
available_implementations.extend(name for key, item in module.__dict__.items() if key.lower() == name
and inspect.isclass(item) and issubclass(item, AbstractDCS))
except ImportError:
logger.info('Failed to import %s', module_name)
raise PatroniFatalException("""Can not find suitable configuration of distributed configuration store
Available implementations: """ + ', '.join(sorted(set(available_implementations))))
except ImportError:
logger.log(logging.DEBUG if config is not None else logging.INFO,
'Failed to import %s', mod_name)
def find_dcs_class_in_module(module: ModuleType) -> Optional[Type['AbstractDCS']]:
"""Try to find the implementation of :class:`AbstractDCS` interface in *module* matching the *module* name.
:param module: Imported DCS module.
:returns: class with a name matching the name of *module* that implements :class:`AbstractDCS` or ``None`` if not
found.
"""
module_name = module.__name__.rpartition('.')[2]
return next(
(obj for obj_name, obj in module.__dict__.items()
if (obj_name.lower() == module_name
and inspect.isclass(obj) and issubclass(obj, AbstractDCS))),
None)
def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
"""Attempt to load a Distributed Configuration Store from known available implementations.
.. note::
Using the list of available DCS modules returned by :func:`iter_dcs_modules` attempt to dynamically import and
instantiate the class that implements a DCS using the abstract class :class:`AbstractDCS`.
Basic top-level configuration parameters retrieved from *config* are propagated to the DCS specific config
before being passed to the module DCS class.
If no module is found to satisfy configuration then report and log an error. This will cause Patroni to exit.
:raises :exc:`PatroniFatalException`: if a load of all available DCS modules have been tried and none succeeded.
:param config: object or dictionary with Patroni configuration. This is normally a representation of the main
Patroni
:returns: The first successfully loaded DCS module which is an implementation of :class:`AbstractDCS`.
"""
for name, dcs_class in iter_dcs_classes(config):
# Propagate some parameters from top level of config if defined to the DCS specific config section.
config[name].update({
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
'patronictl', 'ttl', 'retry_timeout')
if p in config})
# From citus section we only need "group" parameter, but will propagate everything just in case.
if isinstance(config.get('citus'), dict):
config[name].update(config['citus'])
return dcs_class(config[name])
raise PatroniFatalException(
f"Can not find suitable configuration of distributed configuration store\n"
f"Available implementations: {', '.join(sorted([n for n, _ in iter_dcs_classes()]))}")
_Version = Union[int, str]
@@ -508,36 +555,50 @@ class TimelineHistory(NamedTuple):
return TimelineHistory(version, value, lines)
class Cluster(NamedTuple):
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
class Cluster(NamedTuple('Cluster',
[('initialize', Optional[str]),
('config', Optional[ClusterConfig]),
('leader', Optional[Leader]),
('last_lsn', int),
('members', List[Member]),
('failover', Optional[Failover]),
('sync', SyncState),
('history', Optional[TimelineHistory]),
('slots', Optional[Dict[str, int]]),
('failsafe', Optional[Dict[str, str]]),
('workers', Dict[int, 'Cluster'])])):
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
.. note::
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
method. Without it the *workers* by default gets always the same :class:`dict` object that could be mutated.
Consists of the following fields:
:param initialize: shows whether this cluster has initialization key stored in DC or not.
:param config: global dynamic configuration, reference to `ClusterConfig` object
:param leader: `Leader` object which represents current leader of the cluster
:param last_lsn: int or long object containing position of last known leader LSN.
This value is stored in the `/status` key or `/optime/leader` (legacy) key
:param members: list of Member object, all PostgreSQL cluster members including leader
:param failover: reference to `Failover` object
:param sync: reference to `SyncState` object, last observed synchronous replication state.
:param history: reference to `TimelineHistory` object
:param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}
:param failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
:param workers: workers of the Citus cluster, optional. Format: {int(group): Cluster()}
:ivar initialize: shows whether this cluster has initialization key stored in DC or not.
:ivar config: global dynamic configuration, reference to `ClusterConfig` object.
:ivar leader: :class:`Leader` object which represents current leader of the cluster.
:ivar last_lsn: :class:int object containing position of last known leader LSN.
This value is stored in the `/status` key or `/optime/leader` (legacy) key.
:ivar members: list of:class:` Member` objects, all PostgreSQL cluster members including leader
:ivar failover: reference to :class:`Failover` object.
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
:ivar history: reference to `TimelineHistory` object.
:ivar slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}.
:ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
the group, and the corresponding value is a :class:`Cluster` instance.
"""
initialize: Optional[str]
config: Optional[ClusterConfig]
leader: Optional[Leader]
last_lsn: int
members: List[Member]
failover: Optional[Failover]
sync: SyncState
history: Optional[TimelineHistory]
slots: Optional[Dict[str, int]]
failsafe: Optional[Dict[str, str]]
workers: Dict[int, 'Cluster'] = {}
def __new__(cls, *args: Any, **kwargs: Any):
"""Make workers argument optional and set it to an empty dict object."""
if len(args) < len(cls._fields) and 'workers' not in kwargs:
kwargs['workers'] = {}
return super(Cluster, cls).__new__(cls, *args, **kwargs)
@staticmethod
def empty() -> 'Cluster':
"""Produce an empty :class:`Cluster` instance."""
return Cluster(None, None, None, 0, [], None, SyncState.empty(), None, None, None)
def is_empty(self):
@@ -586,24 +647,25 @@ class Cluster(NamedTuple):
def get_replication_slots(self, my_name: str, role: str, nofailover: bool,
major_version: int, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
# the current primary, because that member would replicate from elsewhere. We still create the slot if
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
# primary), or if replicatefrom destination member happens to be the current primary
use_slots = self.use_slots
if role in ('master', 'primary', 'standby_leader'):
slot_members = [m.name for m in self.members if use_slots and m.name != my_name
and (m.replicatefrom is None or m.replicatefrom == my_name
or not self.has_member(m.replicatefrom))]
permanent_slots = self.__permanent_slots if use_slots and \
role in ('master', 'primary') else self.__permanent_physical_slots
else:
# only manage slots for replicas that replicate from this one, except for the leader among them
slot_members = [m.name for m in self.members if use_slots
and m.replicatefrom == my_name and m.name != self.leader_name]
permanent_slots = self.__permanent_logical_slots if use_slots and not nofailover else {}
"""Lookup configured slot names in the DCS, report issues found and merge with permanent slots.
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
Will log an error if:
* Conflicting slot names between members are found
* Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``.
:param my_name: name of this node.
:param role: role of this node.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: postgresql major version.
:param show_error: if ``True`` report error if any disabled logical slots or conflicting slot names are found.
:returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks.
"""
slot_members: List[str] = self._get_slot_members(my_name, role) if self.use_slots else []
slots: Dict[str, Dict[str, str]] = {slot_name_from_member_name(name): {'type': 'physical'}
for name in slot_members}
if len(slots) < len(slot_members):
# Find which names are conflicting for a nicer error message
@@ -611,11 +673,38 @@ class Cluster(NamedTuple):
for name in slot_members:
slot_conflicts[slot_name_from_member_name(name)].append(name)
logger.error("Following cluster members share a replication slot name: %s",
"; ".join("{} map to {}".format(", ".join(v), k)
"; ".join(f"{', '.join(v)} map to {k}"
for k, v in slot_conflicts.items() if len(v) > 1))
# "merge" replication slots for members with permanent_replication_slots
permanent_slots: dict[str, Any] = self._get_permanent_slots(role, nofailover) if self.use_slots else {}
disabled_permanent_logical_slots: List[str] = self._merge_permanent_slots(
slots, permanent_slots, my_name, major_version)
if disabled_permanent_logical_slots and show_error:
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
"Following slots will not be created: %s.", disabled_permanent_logical_slots)
return slots
@staticmethod
def _merge_permanent_slots(slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_name: str,
major_version: int) -> List[str]:
"""Merge replication *slots* for members with *permanent_slots*.
Perform validation of configured permanent slot name, skipping invalid names.
Will update *slots* in-line based on ``type`` of slot, ``physical`` or ``logical``, and name of node.
Type is assumed to be ``physical`` if there are no attributes stored as the slot value.
:param slots: Slot names with existing attributes if known.
:param my_name: name of this node.
:param permanent_slots: dictionary containing slot name key and slot information values.
:param major_version: postgresql major version.
:returns: List of disabled permanent, logical slot names, if postgresql version < 11.
"""
disabled_permanent_logical_slots: List[str] = []
for name, value in permanent_slots.items():
if not slot_name_re.match(name):
logger.error("Invalid permanent replication slot name '%s'", name)
@@ -632,7 +721,8 @@ class Cluster(NamedTuple):
if name != slot_name_from_member_name(my_name):
slots[name] = value
continue
elif value['type'] == 'logical' and value.get('database') and value.get('plugin'):
if value['type'] == 'logical' and value.get('database') and value.get('plugin'):
if major_version < 110000:
disabled_permanent_logical_slots.append(name)
elif name in slots:
@@ -643,12 +733,62 @@ class Cluster(NamedTuple):
continue
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
return disabled_permanent_logical_slots
if disabled_permanent_logical_slots and show_error:
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
"Following slots will not be created: %s.", disabled_permanent_logical_slots)
def _get_permanent_slots(self, role: str, nofailover: bool) -> Dict[str, Any]:
"""Get configured permanent slot names.
return slots
.. note::
Permanent logical replication slots are only considered if ``use_slots`` configuration is enabled. Also,
only considered if *role* is ``primary`` or if it is a promotable ``replica`` -- what excludes a
``standby_leader`` or ``replica`` with ``nofailover`` tag enabled. That combination is used for failing
over logical replication slots, and the latter nodes are not eligible for such task.
Permanent physical slots are only considered if *role* is ``primary`` or ``standby_leader``, independently
if ``use_slots`` is enabled or not. That is done that way because even if Patroni itself is not using slots
to replicate among its members when ``use_slots`` is disabled, the user may still have configured Patroni to
keep permanent physical slots used out of Patroni.
:param role: role of this node -- ``primary``, ``standby_leader`` or ``replica``.
or logical slots being consumed.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:returns: dictionary of permanent slot names mapped to attributes.
"""
if role in ('master', 'primary', 'standby_leader'):
permanent_slots = (self.__permanent_slots
if role in ('master', 'primary')
else self.__permanent_physical_slots)
else:
permanent_slots = self.__permanent_logical_slots if not nofailover else {}
return permanent_slots
def _get_slot_members(self, my_name: str, role: str) -> List[str]:
"""Get a list of member names that have replication slots sourcing from this node.
If the ``replicatefrom`` tag is set on the member - we should not create the replication slot for it on
the current primary, because that member would replicate from elsewhere. We still create the slot if
the ``replicatefrom`` destination member is currently not a member of the cluster (fallback to the
primary), or if ``replicatefrom`` destination member happens to be the current primary.
:param my_name: name of this node.
:param role: role of this node, if this is a ``primary`` or ``standby_leader`` return list of members
replicating from this node. If not then return a list of members replicating as cascaded
replicas from this node.
:returns: list of member names.
"""
if role in ('master', 'primary', 'standby_leader'):
slot_members = [m.name for m in self.members
if m.name != my_name
and (m.replicatefrom is None
or m.replicatefrom == my_name
or not self.has_member(m.replicatefrom))]
else:
# only manage slots for replicas that replicate from this one, except for the leader among them
slot_members = [m.name for m in self.members
if m.replicatefrom == my_name and m.name != self.leader_name]
return slot_members
def has_permanent_logical_slots(self, my_name: str, nofailover: bool, major_version: int = 110000) -> bool:
if major_version < 110000:
@@ -907,7 +1047,9 @@ class AbstractDCS(abc.ABC):
raise
self._last_seen = int(time.time())
self._last_status = {self._OPTIME: cluster.last_lsn, 'slots': cluster.slots}
self._last_status = {self._OPTIME: cluster.last_lsn}
if cluster.slots:
self._last_status['slots'] = cluster.slots
self._last_failsafe = cluster.failsafe
with self._cluster_thread_lock:
+1 -1
View File
@@ -15,7 +15,7 @@ from urllib3.exceptions import HTTPError
from urllib.parse import urlencode, urlparse, quote
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from ..exceptions import DCSError
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
+1 -1
View File
@@ -21,7 +21,7 @@ from urllib.parse import urlparse
from urllib3 import Timeout
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from ..exceptions import DCSError
from ..request import get as requests_get
+11 -1
View File
@@ -15,7 +15,7 @@ from urllib3.exceptions import ReadTimeoutError, ProtocolError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState,\
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \
TimelineHistory, catch_return_false_exception, citus_group_re
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
from ..exceptions import DCSError, PatroniException
@@ -630,6 +630,16 @@ class PatroniEtcd3Client(Etcd3Client):
return ret
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry)
# Here we abuse the fact that the `failure` is only set in the call from update_leader().
# In all other cases the txn() call failure may be an indicator of a stale cache,
# and therefore we want to restart watcher.
if not failure and not ret:
self._restart_watcher()
return ret
class Etcd3(AbstractEtcd):
+21 -6
View File
@@ -19,10 +19,10 @@ from urllib3.exceptions import HTTPError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
from ..exceptions import DCSError
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options,\
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
Retry, RetryFailedError, tzutc, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -752,6 +752,10 @@ class Kubernetes(AbstractDCS):
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
self._namespace = config.get('namespace') or 'default'
self._role_label = config.get('role_label', 'role')
self._leader_label_value = config.get('leader_label_value', 'master')
self._follower_label_value = config.get('follower_label_value', 'replica')
self._standby_leader_label_value = config.get('standby_leader_label_value', 'standby-leader')
self._tmp_role_label = config.get('tmp_role_label')
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
super(Kubernetes, self).__init__({**config, 'namespace': ''})
if self._citus_group:
@@ -1263,19 +1267,30 @@ class Kubernetes(AbstractDCS):
def touch_member(self, data: Dict[str, Any]) -> bool:
cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name == self._name:
role = 'master'
role = self._leader_label_value
tmp_role = 'master'
elif data['state'] == 'running' and data['role'] not in ('master', 'primary'):
role = data['role']
role = {
'replica': self._follower_label_value,
'standby-leader': self._standby_leader_label_value,
}.get(data['role'], data['role'])
tmp_role = data['role']
else:
role = None
tmp_role = None
role_labels = {self._role_label: role}
if self._tmp_role_label:
role_labels[self._tmp_role_label] = tmp_role
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
pod_labels = member and member.data.pop('pod_labels', None)
ret = member and pod_labels is not None\
and pod_labels.get(self._role_label) == role and deep_compare(data, member.data)
and all(pod_labels.get(k) == v for k, v in role_labels.items())\
and deep_compare(data, member.data)
if not ret:
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': {self._role_label: role},
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': role_labels,
'annotations': {'status': json.dumps(data, separators=(',', ':'))}}
body = k8s_client.V1Pod(metadata=k8s_client.V1ObjectMeta(**metadata))
ret = self._api.patch_namespaced_pod(self._name, self._namespace, body)
+65 -22
View File
@@ -56,7 +56,7 @@ class _MemberStatus(NamedTuple):
# If one of those is not in a response we want to count the node as not healthy/reachable
wal: Dict[str, Any] = json.get('wal') or json['xlog']
# abuse difference in primary/replica response format
in_recovery = not bool(wal.get('location')) or json.get('role') in ('master', 'primary')
in_recovery = not (bool(wal.get('location')) or json.get('role') in ('master', 'primary'))
timeline = json.get('timeline', 0)
dcs_last_seen = json.get('dcs_last_seen', 0)
lsn = int(in_recovery and max(wal.get('received_location', 0), wal.get('replayed_location', 0)))
@@ -473,7 +473,8 @@ class Ha(object):
# timeout > 0 indicates that we still have the leader lock, and it was just updated
if timeout\
and data.get('Database cluster state') in ('in production', 'shutting down', 'shut down')\
and data.get('Database cluster state') in ('in production', 'in crash recovery',
'shutting down', 'shut down')\
and self.state_handler.state == 'crashed'\
and self.state_handler.role in ('primary', 'master')\
and not self.state_handler.config.recovery_conf_exists():
@@ -496,6 +497,7 @@ class Ha(object):
role = 'replica'
if self.has_lock() and not self.is_standby_cluster():
self._rewind.reset_state() # we want to later trigger CHECKPOINT after promote
msg = "starting as readonly because i had the session lock"
node_to_follow = None
else:
@@ -525,10 +527,17 @@ class Ha(object):
return msg
def _get_node_to_follow(self, cluster: Cluster) -> Union[Leader, Member, None]:
# determine the node to follow. If replicatefrom tag is set,
# try to follow the node mentioned there, otherwise, follow the leader.
if self.is_standby_cluster() and (self.cluster.is_unlocked() or self.has_lock(False)):
"""Determine the node to follow.
:param cluster: the currently known cluster state from DCS.
:returns: the node which we should be replicating from.
"""
# The standby leader or when there is no standby leader we want to follow
# the remote member, except when there is no standby leader in pause.
if self.is_standby_cluster() and (self.has_lock(False) or self.cluster.is_unlocked() and not self.is_paused()):
node_to_follow = self.get_remote_member()
# If replicatefrom tag is set, try to follow the node mentioned there, otherwise, follow the leader.
elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name:
node_to_follow = cluster.get_member(self.patroni.replicatefrom)
else:
@@ -768,18 +777,14 @@ class Ha(object):
self.state_handler.sync_handler.set_synchronous_standby_names(
CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
if self.state_handler.role not in ('master', 'promoted', 'primary'):
def on_success():
self._rewind.reset_state()
logger.info("cleared rewind state after becoming the leader")
def before_promote():
self.notify_citus_coordinator('before_promote')
with self._async_response:
self._async_response.reset()
self._async_executor.try_run_async('promote', self.state_handler.promote,
args=(self.dcs.loop_wait, self._async_response,
before_promote, on_success))
args=(self.dcs.loop_wait, self._async_response, before_promote))
return promote_message
def fetch_node_status(self, member: Member) -> _MemberStatus:
@@ -998,9 +1003,22 @@ class Ha(object):
return ret
if self.state_handler.is_leader():
# in pause leader is the healthiest only when no initialize or sysid matches with initialize!
return not self.is_paused() or not self.cluster.initialize\
or self.state_handler.sysid == self.cluster.initialize
if self.is_paused():
# in pause leader is the healthiest only when no initialize or sysid matches with initialize!
return not self.cluster.initialize or self.state_handler.sysid == self.cluster.initialize
# We want to protect from the following scenario:
# 1. node1 is stressed so much that heart-beat isn't running regularly and the leader lock expires.
# 2. node2 promotes, gets heavy load and the situation described in 1 repeats.
# 3. Patroni on node1 comes back, notices that Postgres is running as primary but there is
# no leader key and "happily" acquires the leader lock.
# That is, node1 discarded promotion of node2. To avoid it we want to detect timeline change.
my_timeline = self.state_handler.get_primary_timeline()
if my_timeline < self.cluster.timeline:
logger.warning('My timeline %s is behind last known cluster timeline %s',
my_timeline, self.cluster.timeline)
return False
return True
if self.is_paused():
return False
@@ -1084,7 +1102,9 @@ class Ha(object):
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
# for a manual failover/switchover with a candidate, we should check the requested candidate only
if self.is_failover_possible(self.get_failover_candidates(), cluster_lsn=checkpoint_location):
self.state_handler.set_role('demoted')
with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
@@ -1188,15 +1208,12 @@ class Ha(object):
logger.warning('Failover is possible only to a specific candidate in a paused state')
else:
if self.is_synchronous_mode():
if failover.candidate and not self.cluster.sync.matches(failover.candidate):
members = self.get_failover_candidates(check_sync=True)
if failover.candidate and not members:
logger.warning('Failover candidate=%s does not match with sync_standbys=%s',
failover.candidate, self.cluster.sync.sync_standby)
members = []
else:
members = [m for m in self.cluster.members if self.cluster.sync.matches(m.name)]
else:
members = [m for m in self.cluster.members
if not failover.candidate or m.name == failover.candidate]
members = self.get_failover_candidates()
if self.is_failover_possible(members, False): # check that there are healthy members
ret = self._async_executor.try_run_async('manual failover: demote', self.demote, ('graceful',))
return ret or 'manual failover: demoting myself'
@@ -1614,6 +1631,9 @@ class Ha(object):
else:
if self._was_paused:
self.state_handler.schedule_sanity_checks_after_pause()
# during pause people could manually do something with Postgres, therefore we want
# to double check rewind conditions on replicas and maybe run CHECKPOINT on the primary
self._rewind.reset_state()
self._was_paused = False
if not self.cluster.has_member(self.state_handler.name):
@@ -1848,7 +1868,9 @@ class Ha(object):
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
# for a manual failover/switchover with a candidate, we should check the requested candidate only
if self.is_failover_possible(self.get_failover_candidates(), cluster_lsn=checkpoint_location):
self.dcs.delete_leader(checkpoint_location)
status['deleted'] = True
else:
@@ -1908,3 +1930,24 @@ class Ha(object):
name = member.name if member else 'remote_member:{}'.format(uuid.uuid1())
return RemoteMember.from_name_and_data(name, data)
def get_failover_candidates(self, check_sync: bool = False) -> List[Member]:
"""Return list of candidates for either manual or automatic failover.
Mainly used to later be passed to ``Ha.is_failover_possible()``.
:param check_sync: if ``True``, also check against the sync key members
:returns: a list of ``Member`` ojects or an empty list if there is no candidate available
"""
failover = self.cluster.failover
if check_sync:
# TODO: allow manual failover (=no leader specified) to async node
# every sync_standby or the candidate specified if is in sync_standbys
return [m for m in self.cluster.members
if self.cluster.sync.matches(m.name)
and (not failover or not failover.candidate or m.name == failover.candidate)]
else:
# every member or the candidate specified
return [m for m in self.cluster.members
if not failover or not failover.candidate or m.name == failover.candidate]
+2 -4
View File
@@ -1125,8 +1125,8 @@ class Postgresql(object):
except Exception as e:
logger.error('Exception when calling `%s`: %r', cmd, e)
def promote(self, wait_seconds: int, task: CriticalTask, before_promote: Optional[Callable[..., Any]] = None,
on_success: Optional[Callable[..., Any]] = None) -> Optional[bool]:
def promote(self, wait_seconds: int, task: CriticalTask,
before_promote: Optional[Callable[..., Any]] = None) -> Optional[bool]:
if self.role in ('promoted', 'master', 'primary'):
return True
@@ -1152,8 +1152,6 @@ class Postgresql(object):
ret = self.pg_ctl('promote', '-W')
if ret:
self.set_role('promoted')
if on_success is not None:
on_success()
self.call_nowait(CallbackAction.ON_ROLE_CHANGE)
ret = self._wait_promote(wait_seconds)
return ret
+8 -8
View File
@@ -15,7 +15,7 @@ from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
from ..validator import IntValidator
from ..validator import IntValidator, EnumValidator
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
@@ -258,14 +258,14 @@ def _false_validator(value: Any) -> bool:
return False
def _wal_level_validator(value: Any) -> bool:
return str(value).lower() in ('hot_standby', 'replica', 'logical')
def _bool_validator(value: Any) -> bool:
return parse_bool(value) is not None
def _bool_is_true_validator(value: Any) -> bool:
return parse_bool(value) is True
class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options
@@ -286,8 +286,8 @@ class ConfigHandler(object):
'listen_addresses': (None, _false_validator, 90100),
'port': (None, _false_validator, 90100),
'cluster_name': (None, _false_validator, 90500),
'wal_level': ('hot_standby', _wal_level_validator, 90100),
'hot_standby': ('on', _false_validator, 90100),
'wal_level': ('hot_standby', EnumValidator(('hot_standby', 'replica', 'logical')), 90100),
'hot_standby': ('on', _bool_is_true_validator, 90100),
'max_connections': (100, IntValidator(min=25), 90100),
'max_wal_senders': (10, IntValidator(min=3), 90100),
'wal_keep_segments': (8, IntValidator(min=1), 90100),
@@ -297,7 +297,7 @@ class ConfigHandler(object):
'track_commit_timestamp': ('off', _bool_validator, 90500),
'max_replication_slots': (10, IntValidator(min=4), 90400),
'max_worker_processes': (8, IntValidator(min=2), 90400),
'wal_log_hints': ('on', _false_validator, 90400)
'wal_log_hints': ('on', _bool_is_true_validator, 90400)
})
_RECOVERY_PARAMETERS = CaseInsensitiveSet(recovery_parameters.keys())
+2 -2
View File
@@ -280,7 +280,7 @@ class Rewind(object):
"""After promote issue a CHECKPOINT from a new thread and asynchronously check the result.
In case if CHECKPOINT failed, just check that timeline in pg_control was updated."""
if self._state == REWIND_STATUS.INITIAL and self._postgresql.is_leader():
if self._state != REWIND_STATUS.CHECKPOINT and self._postgresql.is_leader():
with self._checkpoint_task_lock:
if self._checkpoint_task:
with self._checkpoint_task:
@@ -370,7 +370,7 @@ class Rewind(object):
# it is the author of archive_command, who is responsible
# for not overriding the WALs already present in archive
logger.info('Trying to archive %s: %s', wal, cmd)
if self._postgresql.cancellable.call(shlex.split(cmd)) == 0:
if self._postgresql.cancellable.call([cmd], shell=True) == 0:
new_name = os.path.join(status_dir, wal + '.done')
try:
shutil.move(old_name, new_name)
+91 -38
View File
@@ -130,7 +130,7 @@ class SlotsHandler(object):
self._postgresql = postgresql
self._advance = None
self._replication_slots: Dict[str, Dict[str, Any]] = {} # already existing replication slots
self._unready_logical_slots: Dict[str, Optional[int]] = {}
self._logical_slots_processing_queue: Dict[str, Optional[int]] = {}
self.pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
self.schedule()
@@ -190,7 +190,8 @@ class SlotsHandler(object):
self._replication_slots = replication_slots
self._schedule_load_slots = False
if self._force_readiness_check:
self._unready_logical_slots = {n: None for n, v in replication_slots.items() if v['type'] == 'logical'}
self._logical_slots_processing_queue = {n: None for n, v in replication_slots.items()
if v['type'] == 'logical'}
self._force_readiness_check = False
def ignore_replication_slot(self, cluster: Cluster, name: str) -> bool:
@@ -327,10 +328,10 @@ class SlotsHandler(object):
self._ensure_physical_slots(slots)
if self._postgresql.is_leader():
self._unready_logical_slots.clear()
self._logical_slots_processing_queue.clear()
self._ensure_logical_slots_primary(slots)
elif cluster.slots and slots:
self.check_logical_slots_readiness(cluster, nofailover, replicatefrom)
self.check_logical_slots_readiness(cluster, replicatefrom)
ret = self._ensure_logical_slots_replica(cluster, slots)
@@ -347,48 +348,100 @@ class SlotsHandler(object):
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
yield cur
def check_logical_slots_readiness(self, cluster: Cluster, nofailover: bool, replicatefrom: Optional[str]) -> None:
def check_logical_slots_readiness(self, cluster: Cluster, replicatefrom: Optional[str]) -> bool:
"""Determine whether all known logical slots are synchronised from the leader.
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
2) using previously stored list of "unready" logical slots, those which have yet to be checked hence have no
stored slot attributes,
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
:param cluster: object containing stateful information for the cluster.
:param replicatefrom: name of the member that should be used to replicate from.
:returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise.
"""
catalog_xmin = None
if self._unready_logical_slots and cluster.leader:
if self._logical_slots_processing_queue and cluster.leader:
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
try:
with self._get_leader_connection_cursor(cluster.leader) as cur:
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)",
([n for n, v in self._unready_logical_slots.items() if v is None] + [slot_name],))
([n for n, v in self._logical_slots_processing_queue.items()
if v is None] + [slot_name],))
slots = {row[0]: row[1] for row in cur}
if slot_name not in slots:
return logger.warning('Physical slot %s does not exist on the primary', slot_name)
logger.warning('Physical slot %s does not exist on the primary', slot_name)
return False
catalog_xmin = slots.pop(slot_name)
except Exception as e:
return logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
# Remember catalog_xmin of logical slots on the primary when catalog_xmin of
# the physical slot became valid. Logical slots on replica will be safe to use after
# promote when catalog_xmin of the physical slot overtakes these values.
if catalog_xmin is not None:
for name, value in slots.items():
self._unready_logical_slots[name] = value
else: # Replica isn't streaming or the hot_standby_feedback isn't enabled
try:
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
row = cur.fetchone()
if row and not row[0]:
logger.error('Logical slot failover requires "hot_standby_feedback".'
' Please check postgresql.auto.conf')
except Exception as e:
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
return # since `catalog_xmin` isn't valid further checks don't make any sense
logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
return False
for name in list(self._unready_logical_slots):
value = self._replication_slots.get(name)
# The logical slot on a replica is safe to use when the physical replica slot on the primary:
# 1. has a nonzero/non-null catalog_xmin
# 2. has a catalog_xmin that is not newer (greater) than the catalog_xmin of any slot on the standby
# 3. overtook the catalog_xmin of remembered values of logical slots on the primary.
if not value or catalog_xmin is not None and\
self._unready_logical_slots[name] <= catalog_xmin <= value['catalog_xmin']:
del self._unready_logical_slots[name]
if value:
if not self._update_pending_logical_slot_primary(slots, catalog_xmin):
return False # since `catalog_xmin` isn't valid further checks don't make any sense
self._ready_logical_slots(catalog_xmin)
return True
def _update_pending_logical_slot_primary(self, slots: Dict[str, Any], catalog_xmin: Optional[int] = None) -> bool:
"""Store pending logical slot information for ``catalog_xmin`` on the primary.
Remember ``catalog_xmin`` of logical slots on the primary when ``catalog_xmin`` of the physical slot became
valid. Logical slots on replica will be safe to use after promote when ``catalog_xmin`` of the physical slot
overtakes these values.
:param slots: dictionary of slot information from the primary
:param catalog_xmin: ``catalog_xmin`` of the physical slot used by this replica to stream changes from primary.
:returns: ``False`` if any issue was faced while processing, ``True`` otherwise.
"""
if catalog_xmin is not None:
for name, value in slots.items():
self._logical_slots_processing_queue[name] = value
return True
# Replica isn't streaming or the hot_standby_feedback isn't enabled
try:
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
row = cur.fetchone()
if row and not row[0]:
logger.error('Logical slot failover requires "hot_standby_feedback".'
' Please check postgresql.auto.conf')
except Exception as e:
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
return False
def _ready_logical_slots(self, primary_physical_catalog_xmin: Optional[int] = None) -> None:
"""Ready logical slots by comparing primary physical slot ``catalog_xmin`` to logical ``catalog_xmin``.
The logical slot on a replica is safe to use when the physical replica slot on the primary:
1. has a nonzero/non-null ``catalog_xmin`` represented by ``primary_physical_xmin``.
2. has a ``catalog_xmin`` that is not newer (greater) than the ``catalog_xmin`` of any slot on the standby
3. overtook the ``catalog_xmin`` of remembered values of logical slots on the primary.
:param primary_physical_catalog_xmin: is the value retrieved from ``pg_catalog.pg_get_replication_slots()`` for
the physical replication slot on the primary.
"""
# Make a copy of processing queue keys as a list as the queue dictionary is modified inside the loop.
for name in list(self._logical_slots_processing_queue):
primary_logical_catalog_xmin = self._logical_slots_processing_queue[name]
standby_logical_slot = self._replication_slots.get(name, {})
standby_logical_catalog_xmin = standby_logical_slot.get('catalog_xmin', 0)
if TYPE_CHECKING: # pragma: no cover
assert primary_logical_catalog_xmin is not None
if (
not standby_logical_slot
or primary_physical_catalog_xmin is not None
and primary_logical_catalog_xmin <= primary_physical_catalog_xmin <= standby_logical_catalog_xmin
):
del self._logical_slots_processing_queue[name]
if standby_logical_slot:
logger.info('Logical slot %s is safe to be used after a failover', name)
def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None:
@@ -433,7 +486,7 @@ class SlotsHandler(object):
shutil.rmtree(slot_dir)
os.rename(slot_tmp_dir, slot_dir)
fsync_dir(slot_dir)
self._unready_logical_slots[name] = None
self._logical_slots_processing_queue[name] = None
fsync_dir(self._postgresql.slots_handler.pg_replslot_dir)
self._postgresql.start()
@@ -446,6 +499,6 @@ class SlotsHandler(object):
if self._advance:
self._advance.on_promote()
if self._unready_logical_slots:
if self._logical_slots_processing_queue:
logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
set(self._unready_logical_slots))
set(self._logical_slots_processing_queue))
+88 -36
View File
@@ -153,6 +153,72 @@ def parse_sync_standby_names(value: str) -> _SSN:
return _SSN(sync_type, has_star, num, members)
class _Replica(NamedTuple):
"""Class representing a single replica that is eligible to be synchronous.
Attributes are taken from ``pg_stat_replication`` view and respective ``Cluster.members``.
:ivar pid: PID of walsender process.
:ivar application_name: matches with the ``Member.name``.
:ivar sync_state: possible values are: ``async``, ``potential``, ``quorum``, and ``sync``.
:ivar lsn: ``write_lsn``, ``flush_lsn``, or ``replay_lsn``, depending on the value of ``synchronous_commit`` GUC.
:ivar nofailover: whether the corresponding member has ``nofailover`` tag set to ``True``.
"""
pid: int
application_name: str
sync_state: str
lsn: int
nofailover: bool
class _ReplicaList(List[_Replica]):
"""A collection of :class:``_Replica`` objects.
Values are reverse ordered by ``_Replica.sync_state`` and ``_Replica.lsn``.
That is, first there will be replicas that have ``sync_state`` == ``sync``, even if they are not
the most up-to-date in term of write/flush/replay LSN. It helps to keep the result of chosing new
synchronous nodes consistent in case if a synchronous standby member is slowed down OR async node
is receiving changes faster than the sync member. Such cases would trigger sync standby member
swapping, but only if lag on this member is exceeding a threshold (``maximum_lag_on_syncnode``).
:ivar max_lsn: maximum value of ``_Replica.lsn`` among all values. In case if there is just one
element in the list we take value of ``pg_current_wal_lsn()``.
"""
def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
"""Create :class:``_ReplicaList`` object.
:param postgresql: reference to :class:``Postgresql`` object.
:param cluster: currently known cluster state from DCS.
"""
super().__init__()
# We want to prioritize candidates based on `write_lsn``, ``flush_lsn``, or ``replay_lsn``.
# Which column exactly to pick depends on the values of ``synchronous_commit`` GUC.
sort_col = {
'remote_apply': 'replay',
'remote_write': 'write'
}.get(postgresql.synchronous_commit(), 'flush') + '_lsn'
members = CaseInsensitiveDict({m.name: m for m in cluster.members})
for row in postgresql.pg_stat_replication():
member = members.get(row['application_name'])
# We want to consider only rows from ``pg_stat_replication` that:
# 1. are known to be streaming (write/flush/replay LSN are not NULL).
# 2. can be mapped to a ``Member`` of the ``Cluster``:
# a. ``Member`` doesn't have ``nosync`` tag set;
# b. PostgreSQL on the member is known to be running and accepting client connections.
if member and row[sort_col] is not None and member.is_running and not member.tags.get('nosync', False):
self.append(_Replica(row['pid'], row['application_name'],
row['sync_state'], row[sort_col], bool(member.nofailover)))
# Prefer replicas that are in state ``sync`` and with higher values of ``write``/``flush``/``replay`` LSN.
self.sort(key=lambda r: (r.sync_state, r.lsn), reverse=True)
self.max_lsn = max(self, key=lambda x: x.lsn).lsn if len(self) > 1 else postgresql.last_operation()
class SyncHandler(object):
"""Class responsible for working with the `synchronous_standby_names`.
@@ -201,6 +267,21 @@ BEGIN
END;$$""")
self._postgresql.reset_cluster_info_state(None) # Reset internal cache to query fresh values
def _process_replica_readiness(self, cluster: Cluster, replica_list: _ReplicaList) -> None:
"""Flags replicas as truly "synchronous" when they have caught up with ``_primary_flush_lsn``.
:param cluster: current cluster topology from DCS
:param replica_list: collection of replicas that we want to evaluate.
"""
for replica in replica_list:
# if standby name is listed in the /sync key we can count it as synchronous, otherwise
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
if replica.application_name not in self._ready_replicas\
and replica.application_name in self._ssn_data.members\
and (cluster.sync.matches(replica.application_name)
or replica.sync_state == 'sync' and replica.lsn >= self._primary_flush_lsn):
self._ready_replicas[replica.application_name] = replica.pid
def current_state(self, cluster: Cluster) -> Tuple[CaseInsensitiveSet, CaseInsensitiveSet]:
"""Finds best candidates to be the synchronous standbys.
@@ -218,31 +299,8 @@ END;$$""")
"""
self._handle_synchronous_standby_names_change()
# Pick candidates based on who has higher replay/remote_write/flush lsn.
sort_col = {
'remote_apply': 'replay',
'remote_write': 'write'
}.get(self._postgresql.synchronous_commit(), 'flush') + '_lsn'
pg_stat_replication = [(r['pid'], r['application_name'], r['sync_state'], r[sort_col])
for r in self._postgresql.pg_stat_replication()
if r[sort_col] is not None]
members = CaseInsensitiveDict({m.name: m for m in cluster.members})
replica_list: List[Tuple[int, str, str, int, bool]] = []
# pg_stat_replication.sync_state has 4 possible states - async, potential, quorum, sync.
# That is, alphabetically they are in the reversed order of priority.
# Since we are doing reversed sort on (sync_state, lsn) tuples, it helps to keep the result
# consistent in case if a synchronous standby member is slowed down OR async node receiving
# changes faster than the sync member (very rare but possible).
# Such cases would trigger sync standby member swapping, but only if lag on a sync node exceeding a threshold.
for pid, app_name, sync_state, replica_lsn in sorted(pg_stat_replication, key=lambda r: r[2:4], reverse=True):
member = members.get(app_name)
if member and member.is_running and not member.tags.get('nosync', False):
replica_list.append((pid, member.name, sync_state, replica_lsn, bool(member.nofailover)))
max_lsn = max(replica_list, key=lambda x: x[3])[3]\
if len(replica_list) > 1 else self._postgresql.last_operation()
replica_list = _ReplicaList(self._postgresql, cluster)
self._process_replica_readiness(cluster, replica_list)
if TYPE_CHECKING: # pragma: no cover
assert self._postgresql.global_config is not None
@@ -253,17 +311,11 @@ END;$$""")
candidates = CaseInsensitiveSet()
sync_nodes = CaseInsensitiveSet()
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
for pid, app_name, sync_state, replica_lsn, _ in sorted(replica_list, key=lambda x: x[4]):
# if standby name is listed in the /sync key we can count it as synchronous, otherwice
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
if app_name not in self._ready_replicas and app_name in self._ssn_data.members and\
(cluster.sync.matches(app_name) or sync_state == 'sync' and replica_lsn >= self._primary_flush_lsn):
self._ready_replicas[app_name] = pid
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
candidates.add(app_name)
if sync_state == 'sync' and app_name in self._ready_replicas:
sync_nodes.add(app_name)
for replica in sorted(replica_list, key=lambda x: x.nofailover):
if sync_node_maxlag <= 0 or replica_list.max_lsn - replica.lsn <= sync_node_maxlag:
candidates.add(replica.application_name)
if replica.sync_state == 'sync' and replica.application_name in self._ready_replicas:
sync_nodes.add(replica.application_name)
if len(candidates) >= sync_node_count:
break
+49 -27
View File
@@ -11,6 +11,19 @@ from .dcs import Member
from .utils import USER_AGENT
class HTTPSConnectionPool(urllib3.HTTPSConnectionPool):
def _validate_conn(self, *args: Any, **kwargs: Any) -> None:
"""Override parent method to silence warnings about requests without certificate verification enabled."""
class PatroniPoolManager(urllib3.PoolManager):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super(PatroniPoolManager, self).__init__(*args, **kwargs)
self.pool_classes_by_scheme = {'http': urllib3.HTTPConnectionPool, 'https': HTTPSConnectionPool}
class PatroniRequest(object):
"""Wrapper for performing requests to Patroni's REST API.
@@ -28,22 +41,30 @@ class PatroniRequest(object):
* If none of the above applies, then it falls back to ``False``.
"""
self._insecure = insecure
self._pool = urllib3.PoolManager(num_pools=10, maxsize=10)
self._pool = PatroniPoolManager(num_pools=10, maxsize=10)
self.reload_config(config)
@staticmethod
def _get_cfg_value(config: Union[Config, Dict[str, Any]], name: str) -> Union[Any, None]:
"""Get value of *name* setting in *config*.
.. note::
*name* key will be searched only under ``ctl`` and ``restapi`` sections, in that order.
def _get_ctl_value(config: Union[Config, Dict[str, Any]], name: str, default: Any = None) -> Optional[Any]:
"""Get value of *name* setting from the ``ctl`` section of the *config*.
:param config: Patroni YAML configuration.
:param name: name of the setting value to be retrieved.
:returns: value of ``ctl -> *name*`` or ``restapi -> *name*``, if either is present, ``None`` otherwise.
:returns: value of ``ctl -> *name*`` if present, ``None`` otherwise.
"""
return config.get('ctl', {}).get(name) or config.get('restapi', {}).get(name)
return config.get('ctl', {}).get(name, default)
@staticmethod
def _get_restapi_value(config: Union[Config, Dict[str, Any]], name: str) -> Optional[Any]:
"""Get value of *name* setting from the ``restapi`` section of the *config*.
:param config: Patroni YAML configuration.
:param name: name of the setting value to be retrieved.
:returns: value of ``restapi -> *name*`` if present, ``None`` otherwise.
"""
return config.get('restapi', {}).get(name)
def _apply_pool_param(self, param: str, value: Any) -> None:
"""Configure *param* as *value* in the request manager.
@@ -65,12 +86,11 @@ class PatroniRequest(object):
* ``cert``: gets translated to ``certfile``
* ``key``: gets translated to ``keyfile``
Will attempt to fetch the requested key first from ``ctl`` section, and fall back to ``restapi`` section
if the former is missing.
Will attempt to fetch the requested key first from ``ctl`` section.
:returns: value of ``ctl -> *name*file`` or ``restapi -> *name*file`` if either is present, ``None`` otherwise.
:returns: value of ``ctl -> *name*file`` if present, ``None`` otherwise.
"""
value = self._get_cfg_value(config, name + 'file')
value = self._get_ctl_value(config, name + 'file')
self._apply_pool_param(name + '_file', value)
return value
@@ -79,37 +99,39 @@ class PatroniRequest(object):
Configure these HTTP headers for requests:
* ``authorization``: based on Patroni' REST API authentication config;
* ``authorization``: based on Patroni' CTL or REST API authentication config;
* ``user-agent``: based on `patroni.utils.USER_AGENT`.
Also configure SSL related settings for requests:
* ``ca_certs`` is configured if ``ctl -> cacert`` or ``restapi -> cafile`` is available;
* ``cert``, ``key`` and ``key_password`` are configured if ``ctl -> certile`` or ``restapi -> certfile`` is
available.
* ``cert``, ``key`` and ``key_password`` are configured if ``ctl -> certfile`` is available.
:param config: Patroni YAML configuration.
"""
# ``restapi -> auth`` is equivalent to ``restapi -> authentication -> username`` + ``:`` +
# ``restapi -> authentication -> password``
self._pool.headers = urllib3.make_headers(basic_auth=self._get_cfg_value(config, 'auth'), user_agent=USER_AGENT)
# ``ctl -> auth`` is equivalent to ``ctl -> authentication -> username`` + ``:`` +
# ``ctl -> authentication -> password``. And the same for ``restapi -> auth``
basic_auth = self._get_ctl_value(config, 'auth') or self._get_restapi_value(config, 'auth')
self._pool.headers = urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_REQUIRED'
insecure = self._insecure if isinstance(self._insecure, bool)\
else self._get_ctl_value(config, 'insecure', False)
insecure = self._insecure if isinstance(self._insecure, bool) else config.get('ctl', {}).get('insecure', False)
if self._apply_ssl_file_param(config, 'cert'):
# With client certificate the cert_reqs must be set to CERT_REQUIRED even if insecure option is used
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_REQUIRED'
# The assert_hostname = False helps to silence warnings
self._pool.connection_pool_kw['assert_hostname'] = False if insecure else None
if insecure: # The assert_hostname = False helps to silence warnings
self._pool.connection_pool_kw['assert_hostname'] = False
self._apply_ssl_file_param(config, 'key')
password = self._get_cfg_value(config, 'keyfile_password')
password = self._get_ctl_value(config, 'keyfile_password')
self._apply_pool_param('key_password', password)
else:
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_NONE' if insecure else 'CERT_REQUIRED'
if insecure: # Disable server certificate validation if requested
self._pool.connection_pool_kw['cert_reqs'] = 'CERT_NONE'
self._pool.connection_pool_kw.pop('assert_hostname', None)
self._pool.connection_pool_kw.pop('key_file', None)
cacert = config.get('ctl', {}).get('cacert') or config.get('restapi', {}).get('cafile')
cacert = self._get_ctl_value(config, 'cacert') or self._get_restapi_value(config, 'cafile')
self._apply_pool_param('ca_certs', cacert)
def request(self, method: str, url: str, body: Optional[Any] = None,
+134 -8
View File
@@ -11,11 +11,12 @@ import shutil
import socket
import subprocess
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, TYPE_CHECKING
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING
from .utils import parse_int, split_host_port, data_directory_is_empty
from .collections import CaseInsensitiveSet
from .dcs import dcs_modules
from .exceptions import ConfigParseError
from .utils import parse_int, split_host_port, data_directory_is_empty
def data_directory_empty(data_dir: str) -> bool:
@@ -784,7 +785,7 @@ class IntValidator(object):
self.base_unit = base_unit
self.raise_assert = raise_assert
def __call__(self, value: Union[int, str]) -> bool:
def __call__(self, value: Any) -> bool:
"""Check if *value* is a valid integer and within the expected range.
.. note::
@@ -802,6 +803,39 @@ class IntValidator(object):
return ret
class EnumValidator(object):
"""Validate enum setting
:ivar allowed_values: a ``set`` or ``CaseInsensitiveSet`` object with allowed enum values.
:ivar raise_assert: if an ``assert`` call should be performed regarding expected type and valid range.
"""
def __init__(self, allowed_values: Tuple[str, ...],
case_sensitive: bool = False, raise_assert: bool = False) -> None:
"""Create an :class:`EnumValidator` object with given allowed values.
:param allowed_values: a tuple with allowed enum values
:param case_sensitive: set to ``True`` to do case sensitive comparisons
:param raise_assert: if an ``assert`` call should be performed regarding expected values.
"""
self.allowed_values = set(allowed_values) if case_sensitive else CaseInsensitiveSet(allowed_values)
self.raise_assert = raise_assert
def __call__(self, value: Any) -> bool:
"""Check if provided *value* could be found within *allowed_values*.
.. note::
If ``raise_assert`` is ``True`` and *value* is not valid, then an ``AssertionError`` will be triggered.
:param value: value to be checked.
:returns: ``True`` if *value* could be found within *allowed_values*.
"""
ret = isinstance(value, str) and value in self.allowed_values
if self.raise_assert:
assert_(ret)
return ret
def validate_watchdog_mode(value: Any) -> None:
"""Validate ``watchdog.mode`` configuration option.
@@ -827,15 +861,44 @@ validate_etcd = {
"srv": str,
"srv_suffix": str,
"url": str,
"proxy": str})
"proxy": str
}),
Optional("protocol"): str,
Optional("username"): str,
Optional("password"): str,
Optional("cacert"): str,
Optional("cert"): str,
Optional("key"): str
}
schema = Schema({
"name": str,
"scope": str,
Optional("ctl"): {
Optional("insecure"): bool,
Optional("cacert"): str,
Optional("certfile"): str,
Optional("keyfile"): str,
Optional("keyfile_password"): str
},
"restapi": {
"listen": validate_host_port_listen,
"connect_address": validate_connect_address,
Optional("authentication"): {
"username": str,
"password": str
},
Optional("certfile"): str,
Optional("keyfile"): str,
Optional("keyfile_password"): str,
Optional("cafile"): str,
Optional("ciphers"): str,
Optional("verify_client"): EnumValidator(("none", "optional", "required"),
case_sensitive=True, raise_assert=True),
Optional("allowlist"): [str],
Optional("allowlist_include_members"): bool,
Optional("http_extra_headers"): dict,
Optional("https_extra_headers"): dict,
Optional("request_queue_size"): IntValidator(min=0, max=4096, raise_assert=True)
},
Optional("bootstrap"): {
@@ -843,15 +906,64 @@ schema = Schema({
Optional("ttl"): int,
Optional("loop_wait"): int,
Optional("retry_timeout"): int,
Optional("maximum_lag_on_failover"): int
Optional("maximum_lag_on_failover"): int,
Optional("maximum_lag_on_syncnode"): int,
Optional("postgresql"): {
Optional("parameters"): {
Optional("max_connections"): int,
Optional("max_locks_per_transaction"): int,
Optional("max_prepared_transactions"): int,
Optional("max_replication_slots"): int,
Optional("max_wal_senders"): int,
Optional("max_worker_processes"): int
},
Optional("use_pg_rewind"): bool,
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): int,
Optional("use_slots"): bool,
},
Optional("primary_start_timeout"): int,
Optional("primary_stop_timeout"): int,
Optional("standby_cluster"): {
Or("host", "port", "restore_command"): Case({
"host": str,
"port": int,
"restore_command": str
}),
Optional("primary_slot_name"): str,
Optional("create_replica_methods"): [str],
Optional("archive_cleanup_command"): str,
Optional("recovery_min_apply_delay"): str
},
Optional("synchronous_mode"): bool,
Optional("synchronous_mode_strict"): bool,
Optional("synchronous_node_count"): int
},
Optional("initdb"): [Or(str, dict)]
Optional("initdb"): [Or(str, dict)],
Optional("method"): str
},
Or(*available_dcs): Case({
"consul": {
Or("host", "url"): Case({
"host": validate_host_port,
"url": str})
"url": str
}),
Optional("port"): int,
Optional("scheme"): str,
Optional("token"): str,
Optional("verify"): bool,
Optional("cacert"): str,
Optional("cert"): str,
Optional("key"): str,
Optional("dc"): str,
Optional("checks"): [str],
Optional("register_service"): bool,
Optional("service_tags"): [str],
Optional("service_check_interval"): str,
Optional("service_check_tls_server_name"): str,
Optional("consistency"): EnumValidator(('default', 'consistent', 'stale'),
case_sensitive=True, raise_assert=True)
},
"etcd": validate_etcd,
"etcd3": validate_etcd,
@@ -869,15 +981,28 @@ schema = Schema({
},
"zookeeper": {
"hosts": Or(comma_separated_host_port, [validate_host_port]),
Optional("use_ssl"): bool,
Optional("cacert"): str,
Optional("cert"): str,
Optional("key"): str,
Optional("key_password"): str,
Optional("verify"): bool,
Optional("set_acls"): dict
},
"kubernetes": {
"labels": {},
Optional("bypass_api_service"): bool,
Optional("namespace"): str,
Optional("scope_label"): str,
Optional("role_label"): str,
Optional("leader_label_value"): str,
Optional("follower_label_value"): str,
Optional("standby_leader_label_value"): str,
Optional("tmp_role_label"): str,
Optional("use_endpoints"): bool,
Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address),
Optional("ports"): [{"name": str, "port": int}],
Optional("cacert"): str,
Optional("retriable_http_codes"): Or(int, [int]),
},
}),
@@ -915,7 +1040,8 @@ schema = Schema({
},
Optional("watchdog"): {
Optional("mode"): validate_watchdog_mode,
Optional("device"): str
Optional("device"): str,
Optional("safety_margin"): int
},
Optional("tags"): {
Optional("nofailover"): bool,
+1 -1
View File
@@ -93,7 +93,7 @@ bootstrap:
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
# post_init: /usr/local/bin/setup_cluster.sh
# Some additional users users which needs to be created after initializing new cluster
# Some additional users which needs to be created after initializing new cluster
users:
admin:
password: admin%
+1 -1
View File
@@ -87,7 +87,7 @@ bootstrap:
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
# post_init: /usr/local/bin/setup_cluster.sh
# Some additional users users which needs to be created after initializing new cluster
# Some additional users which needs to be created after initializing new cluster
users:
admin:
password: admin%
+2 -2
View File
@@ -84,7 +84,7 @@ bootstrap:
- encoding: UTF8
- data-checksums
# Some additional users users which needs to be created after initializing new cluster
# Some additional users which needs to be created after initializing new cluster
users:
admin:
password: admin%
@@ -121,4 +121,4 @@ tags:
nofailover: false
noloadbalance: false
clonefrom: false
replicatefrom: postgres1
# replicatefrom: postgresql1
+2 -2
View File
@@ -226,7 +226,7 @@ class TestRestApiHandler(unittest.TestCase):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary'))
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)),\
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)), \
patch.object(GlobalConfig, 'is_paused', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
@@ -559,7 +559,7 @@ class TestRestApiHandler(unittest.TestCase):
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}'
MockRestApiServer(RestApiHandler, request)
with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)),\
with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)), \
patch.object(MockPatroni, 'dcs') as d:
d.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request)
+9 -9
View File
@@ -155,9 +155,9 @@ class TestBootstrap(BaseTestPostgresql):
config = {'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}}}
with patch.object(Postgresql, 'is_running', Mock(return_value=False)),\
patch.object(Postgresql, 'get_major_version', Mock(return_value=140000)),\
patch('multiprocessing.Process', Mock(side_effect=Exception)),\
with patch.object(Postgresql, 'is_running', Mock(return_value=False)), \
patch.object(Postgresql, 'get_major_version', Mock(return_value=140000)), \
patch('multiprocessing.Process', Mock(side_effect=Exception)), \
patch('multiprocessing.get_context', Mock(side_effect=Exception), create=True):
self.assertRaises(Exception, self.b.bootstrap, config)
with open(os.path.join(self.p.data_dir, 'pg_hba.conf')) as f:
@@ -185,12 +185,12 @@ class TestBootstrap(BaseTestPostgresql):
self.assertFalse(self.b.bootstrap(config))
mock_cancellable_subprocess_call.return_value = 0
with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))),\
patch('multiprocessing.get_context', Mock(side_effect=Exception("42")), create=True),\
patch('os.path.isfile', Mock(return_value=True)),\
patch('os.unlink', Mock()),\
patch.object(ConfigHandler, 'save_configuration_files', Mock()),\
patch.object(ConfigHandler, 'restore_configuration_files', Mock()),\
with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))), \
patch('multiprocessing.get_context', Mock(side_effect=Exception("42")), create=True), \
patch('os.path.isfile', Mock(return_value=True)), \
patch('os.unlink', Mock()), \
patch.object(ConfigHandler, 'save_configuration_files', Mock()), \
patch.object(ConfigHandler, 'restore_configuration_files', Mock()), \
patch.object(ConfigHandler, 'write_recovery_conf', Mock()):
with self.assertRaises(Exception) as e:
self.b.bootstrap(config)
+2 -2
View File
@@ -56,7 +56,7 @@ class TestCitus(BaseTestPostgresql):
'leader': 'leader', 'timeout': 30, 'cooldown': 10})
def test_add_task(self):
with patch('patroni.postgresql.citus.logger.error') as mock_logger,\
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)):
self.c.add_task('', 1, self.cluster, '', None)
mock_logger.assert_called_once()
@@ -120,7 +120,7 @@ class TestCitus(BaseTestPostgresql):
self.c.process_tasks()
self.c.add_task('after_promote', 0, self.cluster, self.cluster.leader_name, 'postgres://host3:5432/postgres')
with patch('patroni.postgresql.citus.logger.error') as mock_logger,\
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
self.c.process_tasks()
mock_logger.assert_called_once()
+2 -1
View File
@@ -21,7 +21,8 @@ class TestConfig(unittest.TestCase):
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
self.assertFalse(self.config.set_dynamic_configuration({'foo': 'bar'}))
self.assertTrue(self.config.set_dynamic_configuration({'standby_cluster': {}, 'postgresql': {
'parameters': {'cluster_name': 1, 'wal_keep_size': 1, 'track_commit_timestamp': 1, 'wal_level': 1}}}))
'parameters': {'cluster_name': 1, 'hot_standby': 1, 'wal_keep_size': 1,
'track_commit_timestamp': 1, 'wal_level': 1}}}))
def test_reload_local_configuration(self):
os.environ.update({
+7 -3
View File
@@ -22,7 +22,7 @@ from .test_ha import get_cluster_initialized_without_leader, get_cluster_initial
@patch('patroni.ctl.load_config', Mock(return_value={
'scope': 'alpha', 'restapi': {'listen': '::', 'certfile': 'a'},
'scope': 'alpha', 'restapi': {'listen': '::', 'certfile': 'a'}, 'ctl': {'certfile': 'a'},
'etcd': {'host': 'localhost:2379'}, 'citus': {'database': 'citus', 'group': 0},
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}}))
class TestCtl(unittest.TestCase):
@@ -83,9 +83,13 @@ class TestCtl(unittest.TestCase):
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
del cluster.members[1].data['conn_url']
for fmt in ('pretty', 'json', 'yaml', 'tsv', 'topology'):
for fmt in ('pretty', 'json', 'yaml', 'topology'):
self.assertIsNone(output_members({}, cluster, name='abc', fmt=fmt))
with patch('click.echo') as mock_echo:
self.assertIsNone(output_members({}, cluster, name='abc', fmt='tsv'))
self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown')
@patch('patroni.ctl.get_dcs')
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
def test_switchover(self, mock_get_dcs):
@@ -451,7 +455,7 @@ class TestCtl(unittest.TestCase):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
for role in self.TEST_ROLES:
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', role], input='y')
result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'restart', '-r', role], input='y')
assert 'No scheduled restart' in result.output
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
+2 -2
View File
@@ -172,12 +172,12 @@ class TestClient(unittest.TestCase):
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])), \
patch.object(EtcdClient, '_load_machines_cache', Mock(side_effect=Exception)):
self.client.http.request = Mock(side_effect=socket.error)
self.assertRaises(etcd.EtcdException, rtry, self.client.api_execute, '/', 'GET', params={'retry': rtry})
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])), \
patch.object(EtcdClient, '_load_machines_cache', Mock(return_value=True)):
self.assertRaises(etcd.EtcdException, rtry, self.client.api_execute, '/', 'GET', params={'retry': rtry})
+11 -4
View File
@@ -5,8 +5,9 @@ import urllib3
from mock import Mock, PropertyMock, patch
from patroni.dcs.etcd import DnsCachingResolver
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3Client, Etcd3Error, Etcd3ClientError, RetryFailedError,\
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode, Etcd3
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
Etcd3Error, Etcd3ClientError, RetryFailedError, InvalidAuthToken, Unavailable, \
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode
from threading import Thread
from . import SleepException, MockResponse
@@ -126,10 +127,16 @@ class TestPatroniEtcd3Client(BaseTestEtcd3):
request = {'key': base64_encode('/patroni/test/leader')}
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.content = '{"succeeded":true,"header":{"revision":"1"}}'
self.client.call_rpc('/kv/txn', {'success': [{'request_delete_range': request}]})
self.client.call_rpc('/kv/put', request)
self.client.call_rpc('/kv/deleterange', request)
@patch.object(urllib3.PoolManager, 'urlopen')
def test_txn(self, mock_urlopen):
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.content = '{"header":{"revision":"1"}}'
self.client.txn({'target': 'MOD', 'mod_revision': '1'},
{'request_delete_range': {'key': base64_encode('/patroni/test/leader')}})
@patch('time.time', Mock(side_effect=[1, 10.9, 100]))
def test__wait_cache(self):
with self.kv_cache.condition:
@@ -241,7 +248,7 @@ class TestEtcd3(BaseTestEtcd3):
self.etcd3.update_leader(leader, '123', failsafe={'foo': 'bar'})
self.etcd3._last_lease_refresh = 0
self.etcd3.update_leader(leader, '124')
with patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(return_value=True)),\
with patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(return_value=True)), \
patch('time.time', Mock(side_effect=[0, 100, 200, 300])):
self.assertRaises(Etcd3Error, self.etcd3.update_leader, leader, '126')
self.etcd3._lease = leader.session
+17 -6
View File
@@ -278,8 +278,10 @@ class TestHa(PostgresInit):
self.p.follow = true
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
self.p.is_running = true
ha_dcs_orig_name = self.ha.dcs.__class__.__name__
self.ha.dcs.__class__.__name__ = 'Raft'
self.assertEqual(self.ha.run_cycle(), 'started as a secondary')
self.ha.dcs.__class__.__name__ = ha_dcs_orig_name
def test_recover_former_primary(self):
self.p.follow = false
@@ -305,7 +307,7 @@ class TestHa(PostgresInit):
self.p.is_running = false
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
self.assertEqual(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)),\
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)), \
patch.object(Ha, 'check_timeline', Mock(return_value=False)):
self.ha._async_executor.schedule('doing crash recovery in a single user mode')
self.ha.state_handler.cancellable._process = Mock()
@@ -338,7 +340,7 @@ class TestHa(PostgresInit):
self.ha._rewind.check_leader_is_not_in_recovery = true
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True)):
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=False)),\
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=False)), \
patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
self.p.follow = true
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
@@ -373,6 +375,12 @@ class TestHa(PostgresInit):
def test_acquire_lock_as_primary(self):
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
def test_leader_race_stale_primary(self):
with patch.object(Postgresql, 'get_primary_timeline', Mock(return_value=1)), \
patch('patroni.ha.logger.warning') as mock_logger:
self.assertEqual(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
self.assertEqual(mock_logger.call_args[0][0], 'My timeline %s is behind last known cluster timeline %s')
def test_promoted_by_acquiring_lock(self):
self.ha.is_healthiest_node = true
self.p.is_leader = false
@@ -606,7 +614,7 @@ class TestHa(PostgresInit):
self.e.initialize = true
self.ha.bootstrap()
self.p.is_leader = true
with patch.object(Watchdog, 'activate', Mock(return_value=False)),\
with patch.object(Watchdog, 'activate', Mock(return_value=False)), \
patch('patroni.ha.logger.error') as mock_logger:
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
@@ -667,9 +675,9 @@ class TestHa(PostgresInit):
self.ha.update_lock = false
self.p.set_role('primary')
with patch('patroni.async_executor.CriticalTask.cancel', Mock(return_value=False)),\
with patch('patroni.async_executor.CriticalTask.cancel', Mock(return_value=False)), \
patch('patroni.async_executor.CriticalTask.result',
PropertyMock(return_value=PostmasterProcess(os.getpid())), create=True),\
PropertyMock(return_value=PostmasterProcess(os.getpid())), create=True), \
patch('patroni.postgresql.Postgresql.terminate_starting_postmaster') as mock_terminate:
self.assertEqual(self.ha.run_cycle(), 'lost leader lock during restart')
mock_terminate.assert_called()
@@ -883,7 +891,10 @@ class TestHa(PostgresInit):
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
self.ha.fetch_node_status(member)
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
self.ha.fetch_node_status(member)
self.ha.patroni.request = Mock()
self.ha.patroni.request.return_value.data = b'{"wal":{"location":1},"role":"primary"}'
ret = self.ha.fetch_node_status(member)
self.assertFalse(ret.in_recovery)
@patch.object(Rewind, 'pg_rewind', true)
@patch.object(Rewind, 'check_leader_is_not_in_recovery', true)
+32 -10
View File
@@ -8,8 +8,8 @@ import unittest
import urllib3
from mock import Mock, PropertyMock, mock_open, patch
from patroni.dcs.kubernetes import Cluster, k8s_client, k8s_config, K8sConfig, K8sConnectionFailed,\
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException,\
from patroni.dcs.kubernetes import Cluster, k8s_client, k8s_config, K8sConfig, K8sConnectionFailed, \
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException, \
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
from threading import Thread
from . import MockResponse, SleepException
@@ -86,8 +86,8 @@ class TestK8sConfig(unittest.TestCase):
with patch('os.environ', env):
self.assertRaises(k8s_config.ConfigException, k8s_config.load_incluster_config)
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}),\
patch('os.path.isfile', Mock(side_effect=[False, True, True, False, True, True, True, True])),\
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}), \
patch('os.path.isfile', Mock(side_effect=[False, True, True, False, True, True, True, True])), \
patch('builtins.open', Mock(side_effect=[
mock_open()(), mock_open(read_data='a')(), mock_open(read_data='a')(),
mock_open()(), mock_open(read_data='a')(), mock_open(read_data='a')()])):
@@ -98,8 +98,8 @@ class TestK8sConfig(unittest.TestCase):
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer a')
def test_refresh_token(self):
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}),\
patch('os.path.isfile', Mock(side_effect=[True, True, False, True, True, True])),\
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}), \
patch('os.path.isfile', Mock(side_effect=[True, True, False, True, True, True])), \
patch('builtins.open', Mock(side_effect=[
mock_open(read_data='cert')(), mock_open(read_data='a')(),
mock_open()(), mock_open(read_data='b')(), mock_open(read_data='c')()])):
@@ -138,10 +138,10 @@ class TestK8sConfig(unittest.TestCase):
config["users"][0]["user"]["client-key-data"] = base64.b64encode(b'foobar').decode('utf-8')
config["clusters"][0]["cluster"]["certificate-authority-data"] = base64.b64encode(b'foobar').decode('utf-8')
with patch('builtins.open', mock_open(read_data=json.dumps(config))),\
patch('os.write', Mock()), patch('os.close', Mock()),\
patch('os.remove') as mock_remove,\
patch('atexit.register') as mock_atexit,\
with patch('builtins.open', mock_open(read_data=json.dumps(config))), \
patch('os.write', Mock()), patch('os.close', Mock()), \
patch('os.remove') as mock_remove, \
patch('atexit.register') as mock_atexit, \
patch('tempfile.mkstemp') as mock_mkstemp:
mock_mkstemp.side_effect = [(3, '1.tmp'), (4, '2.tmp')]
k8s_config.load_kube_config()
@@ -298,6 +298,28 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
self.k.touch_member({'state': 'running', 'role': 'replica'})
self.k.touch_member({'state': 'stopped', 'role': 'primary'})
self.k._role_label = 'isMaster'
self.k._leader_label_value = 'true'
self.k._follower_label_value = 'false'
self.k._standby_leader_label_value = 'false'
self.k._tmp_role_label = 'tmp_role'
self.k.touch_member({'state': 'running', 'role': 'replica'})
mock_patch_namespaced_pod.assert_called()
self.assertEqual(mock_patch_namespaced_pod.call_args.args[2].metadata.labels['isMaster'], 'false')
self.assertEqual(mock_patch_namespaced_pod.call_args.args[2].metadata.labels['tmp_role'], 'replica')
self.k.touch_member({'state': 'running', 'role': 'standby-leader'})
mock_patch_namespaced_pod.assert_called()
self.assertEqual(mock_patch_namespaced_pod.call_args.args[2].metadata.labels['isMaster'], 'false')
self.assertEqual(mock_patch_namespaced_pod.call_args.args[2].metadata.labels['tmp_role'], 'standby-leader')
self.k._name = 'p-0'
self.k.touch_member({'role': 'primary'})
mock_patch_namespaced_pod.assert_called()
self.assertEqual(mock_patch_namespaced_pod.call_args.args[2].metadata.labels['isMaster'], 'true')
self.assertEqual(mock_patch_namespaced_pod.call_args.args[2].metadata.labels['tmp_role'], 'master')
def test_initialize(self):
self.k.initialize()
+1 -1
View File
@@ -43,7 +43,7 @@ class TestPatroniLogger(unittest.TestCase):
_LOG.exception('test')
logger.start()
with patch.object(logging.Handler, 'format', Mock(side_effect=Exception)),\
with patch.object(logging.Handler, 'format', Mock(side_effect=Exception)), \
patch('_pytest.logging.LogCaptureHandler.emit', Mock()):
logging.error('test')
+3 -3
View File
@@ -333,7 +333,7 @@ class TestPostgresql(BaseTestPostgresql):
mock_read_auto = mock_open(read_data=read_data)
mock_read_auto.return_value.__iter__ = lambda o: iter(o.readline, '')
with patch('builtins.open', Mock(side_effect=[mock_open()(), mock_read_auto(), IOError])),\
with patch('builtins.open', Mock(side_effect=[mock_open()(), mock_read_auto(), IOError])), \
patch('os.chmod', Mock()):
self.p.config.write_postgresql_conf()
@@ -496,8 +496,8 @@ class TestPostgresql(BaseTestPostgresql):
self.p.remove_data_directory()
with patch('os.path.isfile', Mock(return_value=True)):
self.p.remove_data_directory()
with patch('os.path.islink', Mock(side_effect=[False, False, True, True])),\
patch('os.listdir', Mock(return_value=['12345'])),\
with patch('os.path.islink', Mock(side_effect=[False, False, True, True])), \
patch('os.listdir', Mock(return_value=['12345'])), \
patch('os.path.realpath', Mock(side_effect=['../foo', '../foo_tsp'])):
self.p.remove_data_directory()
+1 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import time
from mock import Mock, PropertyMock, patch
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL,\
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL, \
Raft, RaftError, SyncObjUtility, TCPTransport, _TCPTransport
from pysyncobj import SyncObjConf, FAIL_REASON
+8 -4
View File
@@ -65,14 +65,14 @@ class TestRewind(BaseTestPostgresql):
def test_pg_rewind(self):
r = {'user': '', 'host': '', 'port': '', 'database': '', 'password': ''}
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=150000)),\
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=150000)), \
patch.object(CancellableSubprocess, 'call', Mock(return_value=None)):
with patch('subprocess.check_output', Mock(return_value=b'boo')):
self.assertFalse(self.r.pg_rewind(r))
with patch('subprocess.check_output', Mock(side_effect=Exception)):
self.assertFalse(self.r.pg_rewind(r))
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=120000)),\
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=120000)), \
patch('subprocess.check_output', Mock(return_value=b'foo %f %p %r %% % %')):
with patch.object(CancellableSubprocess, 'call', mock_cancellable_call):
self.assertFalse(self.r.pg_rewind(r))
@@ -91,7 +91,7 @@ class TestRewind(BaseTestPostgresql):
'Latest checkpoint location': '0/'})):
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
with patch.object(Postgresql, 'is_running', Mock(return_value=True)),\
with patch.object(Postgresql, 'is_running', Mock(return_value=True)), \
patch.object(MockCursor, 'fetchone',
Mock(side_effect=[(0, 0, 1, 1, 0, 0, 0, 0, 0, None, None, None), Exception])):
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
@@ -239,7 +239,7 @@ class TestRewind(BaseTestPostgresql):
with patch('os.listdir', Mock(return_value=['000000000000000000000000.ready'])):
# successful archive_command call
with patch.object(CancellableSubprocess, 'call', Mock(return_value=0)):
with patch.object(CancellableSubprocess, 'call', Mock(return_value=0)) as mock_subprocess_call:
get_guc_value_res = [
'on', 'command %f',
'always', 'command %f',
@@ -252,6 +252,10 @@ class TestRewind(BaseTestPostgresql):
'000000000000000000000000', 'command 000000000000000000000000'),
mock_logger_info.call_args[0])
mock_logger_info.reset_mock()
mock_subprocess_call.assert_called_once()
self.assertEqual(mock_subprocess_call.call_args.args[0], ['command 000000000000000000000000'])
self.assertEqual(mock_subprocess_call.call_args.kwargs['shell'], True)
mock_subprocess_call.reset_mock()
# failed archive_command call
with patch.object(CancellableSubprocess, 'call', Mock(return_value=1)):
+11 -11
View File
@@ -43,12 +43,12 @@ class TestSlotsHandler(BaseTestPostgresql):
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
self.s.sync_replication_slots(cluster, False)
self.p.set_role('standby_leader')
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))),\
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))), \
patch('patroni.postgresql.slots.logger.debug') as mock_debug:
self.s.sync_replication_slots(cluster, False)
mock_debug.assert_called_once()
self.p.set_role('replica')
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)),\
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)), \
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
self.s.sync_replication_slots(cluster, False, paused=True)
mock_drop.assert_not_called()
@@ -93,11 +93,11 @@ class TestSlotsHandler(BaseTestPostgresql):
def test__ensure_logical_slots_replica(self):
self.p.set_role('replica')
self.cluster.slots['ls'] = 12346
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock()):
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock(return_value=False)):
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
self.s._schedule_load_slots = False
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))),\
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))), \
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
@@ -119,14 +119,14 @@ class TestSlotsHandler(BaseTestPostgresql):
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test_check_logical_slots_readiness(self):
self.s.copy_logical_slots(self.cluster, ['ls'])
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))),\
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
patch.object(MockCursor, 'fetchone', Mock(side_effect=Exception)):
self.assertIsNone(self.s.check_logical_slots_readiness(self.cluster, False, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))),\
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
patch.object(MockCursor, 'fetchone', Mock(return_value=(False,))):
self.assertIsNone(self.s.check_logical_slots_readiness(self.cluster, False, None))
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('ls', 100)]))):
self.s.check_logical_slots_readiness(self.cluster, False, None)
self.s.check_logical_slots_readiness(self.cluster, None)
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@@ -144,7 +144,7 @@ class TestSlotsHandler(BaseTestPostgresql):
self.assertRaises(OSError, fsync_dir, 'foo')
def test_slots_advance_thread(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
self.s.schedule_advance_slots({'foo': {'bar': 100}})
+2 -1
View File
@@ -15,7 +15,8 @@ config = {
"scope": "string",
"restapi": {
"listen": "127.0.0.2:800",
"connect_address": "127.0.0.2:800"
"connect_address": "127.0.0.2:800",
"verify_client": 'none'
},
"bootstrap": {
"dcs": {
+1 -1
View File
@@ -7,7 +7,7 @@ from kazoo.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import KeeperState, ZnodeStat
from kazoo.retry import RetryFailedError
from mock import Mock, PropertyMock, patch
from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient,\
from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient, \
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
+2 -1
View File
@@ -1,6 +1,7 @@
from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool
from .poolmanager import PoolManager
from .response import HTTPResponse
from .util.request import make_headers
from .util.timeout import Timeout
__all__ = ['HTTPResponse', 'PoolManager', 'Timeout', 'make_headers']
__all__ = ['HTTPResponse', 'HTTPConnectionPool', 'HTTPSConnectionPool', 'PoolManager', 'Timeout', 'make_headers']
+2
View File
@@ -0,0 +1,2 @@
class HTTPConnectionPool: ...
class HTTPSConnectionPool(HTTPConnectionPool): ...