mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e32775df | ||
|
|
f2919f9c2f | ||
|
|
f59c79740f | ||
|
|
2a64bfd459 | ||
|
|
23067d7ea7 | ||
|
|
47063de46d | ||
|
|
3e9bceac11 | ||
|
|
9cc1f8e763 | ||
|
|
d00f5a645b | ||
|
|
15b57c5bdc | ||
|
|
f10e4805db | ||
|
|
3e0e91f905 | ||
|
|
51a148fcf3 | ||
|
|
c3697738b1 | ||
|
|
722b4b72a8 | ||
|
|
65b43c39fa | ||
|
|
aea9a2b0ca | ||
|
|
71ccd41915 | ||
|
|
49e4a6ed7d | ||
|
|
ebd05871d9 | ||
|
|
42cd803619 | ||
|
|
bae72df5b1 | ||
|
|
f2a129f209 | ||
|
|
df0fd91614 | ||
|
|
43f23df974 | ||
|
|
42bf1f95a3 | ||
|
|
23200daada | ||
|
|
ce10e5fccc | ||
|
|
bb90feb393 | ||
|
|
3d527f5728 | ||
|
|
6c06f5cc96 | ||
|
|
f5ee67fa1c | ||
|
|
65030c56ee | ||
|
|
d471f1156d | ||
|
|
6d98944e73 | ||
|
|
6cfd90401e | ||
|
|
ce187bec38 | ||
|
|
c5fffb3c97 | ||
|
|
cb5f34b721 | ||
|
|
260ab36f2e | ||
|
|
fc67ba73f0 | ||
|
|
60d8bc3a70 | ||
|
|
e513f7f127 | ||
|
|
aa3ebe0af8 | ||
|
|
c96e35c807 | ||
|
|
88b35252c3 | ||
|
|
d93db20baa | ||
|
|
42976df86f | ||
|
|
6f4c2fe132 | ||
|
|
588df5da05 | ||
|
|
fb367cd73e | ||
|
|
535dc631ec | ||
|
|
9b8c40a6e1 | ||
|
|
e19a8730ea | ||
|
|
28a604983b | ||
|
|
efacc6c16b | ||
|
|
9283ebda64 | ||
|
|
a329a9d320 | ||
|
|
f77073c8e1 | ||
|
|
aaac6f6fb0 | ||
|
|
27915984b4 | ||
|
|
220cacd95f | ||
|
|
a3b3e1bc1c | ||
|
|
c855b0bff9 | ||
|
|
4c1c804cfd | ||
|
|
48514db84b | ||
|
|
2bd821a768 | ||
|
|
fe16c3610e | ||
|
|
bc15813de0 | ||
|
|
18d9cb1124 | ||
|
|
66bdb1ae12 | ||
|
|
28b9d3d2d9 | ||
|
|
25ceb68257 | ||
|
|
5a504e67c1 | ||
|
|
75dbe4ff96 | ||
|
|
71863cedcb | ||
|
|
728abfcc37 | ||
|
|
238b8db91e | ||
|
|
b31a4d55c9 | ||
|
|
3c24c33e59 | ||
|
|
83a060fc15 | ||
|
|
a2ceff1517 | ||
|
|
19f20ec2eb | ||
|
|
30f0f132e8 | ||
|
|
941e883dde | ||
|
|
89a162e000 | ||
|
|
0ab5b49757 | ||
|
|
80a03a4892 | ||
|
|
d2603402ea | ||
|
|
6b7f914da7 | ||
|
|
03107e6d8b | ||
|
|
77dba39585 | ||
|
|
89d794facc | ||
|
|
3333e78500 | ||
|
|
0ab4bc9d27 | ||
|
|
13cfe0af36 | ||
|
|
7319d12026 | ||
|
|
2ec9834c60 | ||
|
|
2be64e5131 | ||
|
|
93be10a655 | ||
|
|
366829e379 | ||
|
|
899cad1c0f | ||
|
|
a4ac4963d1 | ||
|
|
704d36815a | ||
|
|
4138d0b830 | ||
|
|
b7ea511511 | ||
|
|
badf1da183 | ||
|
|
6a75b1591b | ||
|
|
82d2ef4878 | ||
|
|
b83f1c0f44 | ||
|
|
9209a5a133 | ||
|
|
3734ecc851 | ||
|
|
713244975c | ||
|
|
efaba9f183 | ||
|
|
f24db395c6 | ||
|
|
9dd177e5c9 | ||
|
|
eb100fd586 | ||
|
|
a74985f41d | ||
|
|
da9aaf6cdf | ||
|
|
84aac437c1 | ||
|
|
48e3d31e1d | ||
|
|
01d07f86cd | ||
|
|
b6fc4bc393 | ||
|
|
018a2f4dd9 | ||
|
|
b7caf3b7f2 | ||
|
|
ec61aede85 | ||
|
|
e4703d4f74 | ||
|
|
a26e46cf76 | ||
|
|
94bfea1a81 | ||
|
|
01976ec10b | ||
|
|
8f3ed00886 | ||
|
|
7e89583ec7 | ||
|
|
2735c937fd | ||
|
|
384a2a4d8f | ||
|
|
238aba3956 | ||
|
|
ae2bbd28ae | ||
|
|
0e19e3e98e | ||
|
|
06db296612 | ||
|
|
817f39ad6d | ||
|
|
c5a4befdc4 | ||
|
|
e860cac348 | ||
|
|
48164774c2 | ||
|
|
0a8fb0860e | ||
|
|
ffd1ad97d2 | ||
|
|
84c574e1ec | ||
|
|
cb9998ade6 | ||
|
|
4830e36e2b | ||
|
|
0c5bf3c4cd | ||
|
|
480b8dbf95 | ||
|
|
a4d29eb99e | ||
|
|
d46ca88e6b | ||
|
|
665f49b320 | ||
|
|
47854d77e8 | ||
|
|
e4fe239a9d | ||
|
|
6e96db173f | ||
|
|
b8cff3515a | ||
|
|
412c51ddf1 | ||
|
|
4725f12f9a | ||
|
|
3c1b274ab7 | ||
|
|
35c97fa402 | ||
|
|
1c36112b44 | ||
|
|
768d563fba | ||
|
|
4b023bc9ad | ||
|
|
c4f8e72765 | ||
|
|
0eea239f6b | ||
|
|
e72d3ba79e | ||
|
|
ed02826103 | ||
|
|
74d78dbba2 | ||
|
|
6f91f4f4e2 | ||
|
|
43e2290fdf | ||
|
|
a00ffcb1a6 | ||
|
|
9b01041175 | ||
|
|
2354f8f004 | ||
|
|
bd951ccdef | ||
|
|
e9f9e1cfad | ||
|
|
4e52d4bb2e | ||
|
|
0cf2083161 | ||
|
|
4b960477bb | ||
|
|
21e92fd166 | ||
|
|
af318b2473 | ||
|
|
f3c80d5706 | ||
|
|
df18885f20 | ||
|
|
d11328020d | ||
|
|
37fffa618f | ||
|
|
101ea10e98 | ||
|
|
2158f4a87b | ||
|
|
af8e5f0d0f | ||
|
|
1c7bf2f59e | ||
|
|
822b6ec711 | ||
|
|
73797e8572 | ||
|
|
b4afc6830b | ||
|
|
d1fdb45179 | ||
|
|
6c8a3b0d25 | ||
|
|
0ead20f6a4 | ||
|
|
2f5bcbd877 | ||
|
|
db71ba3955 | ||
|
|
e0a4a0c6a6 | ||
|
|
506b5bec48 | ||
|
|
44e58a1ba1 | ||
|
|
66a0e44371 | ||
|
|
fdcf8b1997 | ||
|
|
ab9fea7d6b | ||
|
|
7941c86775 | ||
|
|
13164daf28 | ||
|
|
76b3b99de2 | ||
|
|
1ac9b11f33 | ||
|
|
4e1b9937b9 | ||
|
|
90ed581d87 | ||
|
|
bebe6754fc | ||
|
|
4d35f85b87 | ||
|
|
54b6d8186f | ||
|
|
ff6d728f07 | ||
|
|
4f458baa0e | ||
|
|
7c0a565985 | ||
|
|
782ebda77e | ||
|
|
24af774adb | ||
|
|
8a5d6ec74d | ||
|
|
6ffc73946a | ||
|
|
e30d96a468 | ||
|
|
2c7b547a29 | ||
|
|
3fe2a7868a | ||
|
|
6f357a4e17 | ||
|
|
c549ea7d5c | ||
|
|
1003af6d20 | ||
|
|
786f7eba97 | ||
|
|
f42bab5081 | ||
|
|
39875f448c | ||
|
|
a1095e385c | ||
|
|
84353b88c9 | ||
|
|
60723f5fa4 | ||
|
|
a8b90f0cd6 | ||
|
|
918674e7bb | ||
|
|
ddac8683e6 | ||
|
|
36c17e944b | ||
|
|
c1bfb0e6d6 | ||
|
|
373affe707 | ||
|
|
95ba8b9e59 | ||
|
|
60a7e5a514 | ||
|
|
eefa15b390 | ||
|
|
8622fcea3d | ||
|
|
2afcaa9d83 | ||
|
|
09d0d78b74 | ||
|
|
89595babdf | ||
|
|
dff5537954 | ||
|
|
c985974ece | ||
|
|
388bb40b71 | ||
|
|
422047f105 | ||
|
|
b85f155dbe | ||
|
|
1669a49b2d | ||
|
|
8ac8ed6584 |
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment**
|
||||
- Patroni version:
|
||||
- PostgreSQL version:
|
||||
- DCS (and its version):
|
||||
|
||||
**Patroni configuration file**
|
||||
```
|
||||
Please copy&paste your Patroni configuration file here
|
||||
```
|
||||
|
||||
**patronictl show-config**
|
||||
```
|
||||
Please copy&paste the output of "patronictl show-config" command here
|
||||
```
|
||||
|
||||
**Have you checked Patroni logs?**
|
||||
Please provide a snippet of Patroni log files here
|
||||
|
||||
**Have you checked PostgreSQL logs?**
|
||||
Please provide a snippet here
|
||||
|
||||
**Have you tried to use GitHub issue search?**
|
||||
Maybe there is already a similar issue solved.
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If you have a question please post it on channel [#patroni](https://postgresteam.slack.com/archives/C9XPYG92A) in the [PostgreSQL Slack](https://pgtreats.info/slack-invite).
|
||||
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
|
||||
Please fill the form below and provide as much information as possible.
|
||||
Not doing so may result in your bug not being addressed in a timely manner.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: How can we reproduce it (as minimally and precisely as possible)?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Patroni/PostgreSQL/DCS version
|
||||
value: |
|
||||
- Patroni version:
|
||||
- PostgreSQL version:
|
||||
- DCS (and its version):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: patroniConfig
|
||||
attributes:
|
||||
label: Patroni configuration file
|
||||
description: Please copy and paste Patroni configuration file here. This will be automatically formatted into code, so no need for backticks.
|
||||
render: yaml
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: globalConfig
|
||||
attributes:
|
||||
label: patronictl show-config
|
||||
description: Please copy and paste `patronictl show-config` output here. This will be automatically formatted into code, so no need for backticks.
|
||||
render: yaml
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: patroniLogs
|
||||
attributes:
|
||||
label: Patroni log files
|
||||
description: Please copy and paste any relevant Patroni log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: postgresLogs
|
||||
attributes:
|
||||
label: PostgreSQL log files
|
||||
description: Please copy and paste any relevant PostgreSQL log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: issueSearch
|
||||
attributes:
|
||||
label: Have you tried to use GitHub issue search?
|
||||
description: Maybe there is already a similar issue solved.
|
||||
options:
|
||||
- label: 'Yes'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Anything else we need to know?
|
||||
description: Add any other context about the problem here.
|
||||
@@ -1 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question
|
||||
url: https://pgtreats.info/slack-invite
|
||||
about: "Please ask questions on channel #patroni in the PostgreSQL Slack"
|
||||
|
||||
@@ -19,7 +19,7 @@ def install_requirements(what):
|
||||
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
|
||||
requirements += ['coverage']
|
||||
# try to split tests between psycopg2 and psycopg3
|
||||
requirements += ['psycopg[binary]'] if sys.version_info > (3, 7, 0) and\
|
||||
requirements += ['psycopg[binary]'] if sys.version_info >= (3, 8, 0) and\
|
||||
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
|
||||
for r in read('requirements.txt').split('\n'):
|
||||
r = r.strip()
|
||||
@@ -45,6 +45,8 @@ def install_packages(what):
|
||||
packages['exhibitor'] = packages['zookeeper']
|
||||
packages = packages.get(what, [])
|
||||
ver = versions.get(what)
|
||||
if float(ver) >= 15:
|
||||
packages += ['postgresql-{0}-citus-12.1'.format(ver)]
|
||||
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
|
||||
|
||||
@@ -83,8 +85,8 @@ def unzip_all(archive):
|
||||
|
||||
|
||||
def chmod_755(name):
|
||||
os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |
|
||||
stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
|
||||
| stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
|
||||
|
||||
def unpack(archive, name):
|
||||
|
||||
@@ -1 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
|
||||
@@ -24,8 +24,11 @@ jobs:
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Install Python packaging build frontend
|
||||
run: python -m pip install build
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: python setup.py sdist bdist_wheel
|
||||
run: python -m build
|
||||
|
||||
- name: Publish distribution to Test PyPI
|
||||
if: github.event_name == 'push'
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 'REL_[0-9]+_[0-9]+'
|
||||
|
||||
env:
|
||||
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
@@ -110,12 +111,14 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: nolar/setup-k3d-k3s@v1
|
||||
if: matrix.dcs == 'kubernetes'
|
||||
- name: Add postgresql apt repo
|
||||
- name: Add postgresql and citus apt repo
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y wget ca-certificates gnupg
|
||||
sudo apt-get install -y wget ca-certificates gnupg debian-archive-keyring apt-transport-https
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
sudo sh -c 'wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg'
|
||||
sudo sh -c 'echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/ubuntu/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list'
|
||||
sudo sh -c 'wget -qO - https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg'
|
||||
if: matrix.os == 'ubuntu'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
@@ -155,3 +158,44 @@ jobs:
|
||||
steps:
|
||||
- run: bash <(curl -Ls https://coverage.codacy.com/get.sh) final
|
||||
if: ${{ env.SECRETS_AVAILABLE == 'true' }}
|
||||
|
||||
pyright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install -r requirements.txt psycopg2-binary psycopg
|
||||
|
||||
- uses: jakebailey/pyright-action@v1
|
||||
with:
|
||||
version: 1.1.347
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.11
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install tox
|
||||
|
||||
- name: Install package dependencies
|
||||
run: |
|
||||
sudo apt update \
|
||||
&& sudo apt install -y \
|
||||
latexmk texlive-latex-extra tex-gyre \
|
||||
--no-install-recommends
|
||||
|
||||
- name: Generate documentation
|
||||
run: tox -m docs
|
||||
|
||||
+13
-1
@@ -33,7 +33,7 @@ nosetests.xml
|
||||
coverage.xml
|
||||
htmlcov
|
||||
junit.xml
|
||||
features/output
|
||||
features/output*
|
||||
dummy
|
||||
|
||||
# Translations
|
||||
@@ -48,12 +48,24 @@ pgpass
|
||||
scm-source.json
|
||||
|
||||
# Sphinx-generated documentation
|
||||
docs/_build/
|
||||
docs/build/
|
||||
docs/source/_static/
|
||||
docs/source/_templates/
|
||||
docs/modules/
|
||||
docs/pdf/
|
||||
|
||||
# Pycharm IDE
|
||||
.idea/
|
||||
|
||||
#VSCode IDE
|
||||
.vscode/
|
||||
|
||||
# Virtual environment
|
||||
venv*/
|
||||
|
||||
# Default test data directory
|
||||
data/
|
||||
|
||||
# macOS
|
||||
**/.DS_Store
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
formats:
|
||||
- epub
|
||||
- pdf
|
||||
- htmlzip
|
||||
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.docs.txt
|
||||
- requirements: requirements.txt
|
||||
+21
-9
@@ -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 \
|
||||
@@ -53,14 +52,26 @@ RUN set -ex \
|
||||
&& curl -sL "https://github.com/coreos/etcd/releases/download/v$ETCDVERSION/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
|
||||
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
|
||||
\
|
||||
# Download confd
|
||||
&& curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
|
||||
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
# Build confd
|
||||
apt-get install -y git make \
|
||||
&& curl -sL https://go.dev/dl/go1.20.4.linux-arm64.tar.gz | tar xz -C /usr/local go \
|
||||
&& export GOROOT=/usr/local/go && export PATH=$PATH:$GOROOT/bin \
|
||||
&& git clone --recurse-submodules https://github.com/kelseyhightower/confd.git \
|
||||
&& make -C confd \
|
||||
&& cp confd/bin/confd /usr/local/bin/confd \
|
||||
&& rm -rf /confd /usr/local/go; \
|
||||
else \
|
||||
# Download confd
|
||||
curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
|
||||
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd; \
|
||||
fi \
|
||||
\
|
||||
# Clean up all useless packages and some files
|
||||
&& 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 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
@@ -143,14 +154,15 @@ WORKDIR $PGHOME
|
||||
|
||||
RUN sed -i 's/env python/&3/' /patroni*.py \
|
||||
# "fix" patroni configs
|
||||
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
|
||||
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
|
||||
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
|
||||
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
|
||||
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
|
||||
&& sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \
|
||||
&& sed -i 's/^\(scope\|name\|etcd\| host\| authentication\| connect_address\| parameters\):/#&/' postgres?.yml \
|
||||
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
|
||||
&& sed -i 's/^ parameters:/ pg_hba:\n - local all all trust\n - host replication all all md5\n - host all all all md5\n&\n max_connections: 100/' postgres?.yml \
|
||||
&& sed -i 's/^ parameters:/&\n max_connections: 100/' postgres?.yml \
|
||||
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' postgres?.yml \
|
||||
&& sed -i 's/^\(.*\) \(.*\) md5/\1 all md5/' postgres?.yml \
|
||||
&& if [ "$COMPRESS" = "true" ]; then chmod u+s /usr/bin/sudo; fi \
|
||||
&& chmod +s /bin/ping \
|
||||
&& chown -R postgres:postgres "$PGHOME" /run /etc/haproxy
|
||||
|
||||
+39
-12
@@ -20,15 +20,28 @@ RUN set -ex \
|
||||
&& export DEBIAN_FRONTEND=noninteractive \
|
||||
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
|
||||
&& apt-get update -y \
|
||||
# postgres:10 is based on debian, which has the patroni package. We will install all required dependencies
|
||||
# postgres:PG_MAJOR is based on debian, which has the patroni package. We will install all required dependencies
|
||||
&& apt-cache depends patroni | sed -n -e 's/.*Depends: \(python3-.\+\)$/\1/p' \
|
||||
| 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 \
|
||||
&& curl https://install.citusdata.com/community/deb.sh | bash \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.1 \
|
||||
&& pip3 install dumb-init \
|
||||
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 \
|
||||
libc6-dev flex libcurl4-gnutls-dev \
|
||||
libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev\
|
||||
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& git clone -b "main" https://github.com/citusdata/citus.git \
|
||||
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
|
||||
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
|
||||
else \
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
|
||||
fi \
|
||||
\
|
||||
# Cleanup all locales but en_US.UTF-8
|
||||
&& find /usr/share/i18n/charmaps/ -type f ! -name UTF-8.gz -delete \
|
||||
@@ -55,16 +68,29 @@ RUN set -ex \
|
||||
&& curl -sL https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-$(dpkg --print-architecture).tar.gz \
|
||||
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl \
|
||||
\
|
||||
# Download confd
|
||||
&& curl -sL https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-$(dpkg --print-architecture) \
|
||||
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
# Build confd
|
||||
curl -sL https://go.dev/dl/go1.20.4.linux-arm64.tar.gz | tar xz -C /usr/local go \
|
||||
&& export GOROOT=/usr/local/go && export PATH=$PATH:$GOROOT/bin \
|
||||
&& git clone --recurse-submodules https://github.com/kelseyhightower/confd.git \
|
||||
&& make -C confd \
|
||||
&& cp confd/bin/confd /usr/local/bin/confd \
|
||||
&& rm -rf /confd /usr/local/go; \
|
||||
else \
|
||||
# Download confd
|
||||
curl -sL "https://github.com/kelseyhightower/confd/releases/download/v$CONFDVERSION/confd-$CONFDVERSION-linux-$(dpkg --print-architecture)" \
|
||||
> /usr/local/bin/confd && chmod +x /usr/local/bin/confd; \
|
||||
fi \
|
||||
# Prepare client cert for HAProxy
|
||||
&& cat /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/certs/ssl-cert-snakeoil.pem > /etc/ssl/private/ssl-cert-snakeoil.crt \
|
||||
\
|
||||
# Clean up all useless packages and some files
|
||||
&& 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 \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
@@ -149,16 +175,17 @@ WORKDIR $PGHOME
|
||||
|
||||
RUN sed -i 's/env python/&3/' /patroni*.py \
|
||||
# "fix" patroni configs
|
||||
&& sed -i 's/^\( connect_address:\| - host\)/#&/' postgres?.yml \
|
||||
&& sed -i 's/^ listen: 127.0.0.1/ listen: 0.0.0.0/' postgres?.yml \
|
||||
&& sed -i "s|^\( data_dir: \).*|\1$PGDATA|" postgres?.yml \
|
||||
&& sed -i "s|^#\( bin_dir: \).*|\1$PGBIN|" postgres?.yml \
|
||||
&& sed -i 's/^ - encoding: UTF8/ - locale: en_US.UTF-8\n&/' postgres?.yml \
|
||||
&& sed -i 's/^scope:/log:\n loggers:\n patroni.postgresql.citus: DEBUG\n#&/' postgres?.yml \
|
||||
&& sed -i 's/^\(name\|etcd\| host\| authentication\| pg_hba\| parameters\):/#&/' postgres?.yml \
|
||||
&& sed -i 's/^\(name\|etcd\| host\| authentication\| connect_address\| parameters\):/#&/' postgres?.yml \
|
||||
&& sed -i 's/^ \(replication\|superuser\|rewind\|unix_socket_directories\|\(\( \)\{0,1\}\(username\|password\)\)\):/#&/' postgres?.yml \
|
||||
&& sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' postgres?.yml \
|
||||
&& sed -i 's|^ parameters:| pg_hba:\n - local all all trust\n - hostssl replication all all md5 clientcert=verify-ca\n - hostssl all all all md5 clientcert=verify-ca\n&\n max_connections: 100\n shared_buffers: 16MB\n ssl: "on"\n ssl_ca_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key\n citus.node_conninfo: "sslrootcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslkey=/etc/ssl/private/ssl-cert-snakeoil.key sslcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslmode=verify-ca"|' postgres?.yml \
|
||||
&& sed -i 's|^ parameters:|&\n max_connections: 100\n shared_buffers: 16MB\n ssl: "on"\n ssl_ca_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem\n ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key\n citus.node_conninfo: "sslrootcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslkey=/etc/ssl/private/ssl-cert-snakeoil.key sslcert=/etc/ssl/certs/ssl-cert-snakeoil.pem sslmode=verify-ca"|' postgres?.yml \
|
||||
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' postgres?.yml \
|
||||
&& sed -i 's/^\(.*\) \(.*\) \(.*\) \(.*\) \(.*\) md5.*$/\1 hostssl \3 \4 all md5 clientcert=verify-ca/' postgres?.yml \
|
||||
&& sed -i 's/^#\(ctl\| certfile\| keyfile\)/\1/' postgres?.yml \
|
||||
&& sed -i 's|^# cafile: .*$| verify_client: required\n cafile: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \
|
||||
&& sed -i 's|^# cacert: .*$| cacert: /etc/ssl/certs/ssl-cert-snakeoil.pem|' postgres?.yml \
|
||||
|
||||
+18
-26
@@ -8,13 +8,13 @@ You can find a version of this documentation that is searchable and also easier
|
||||
|
||||
There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
|
||||
|
||||
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful.
|
||||
Patroni is a template for high availability (HA) PostgreSQL solutions using Python. For maximum accessibility, Patroni supports a variety of distributed configuration stores like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in datacenters - or anywhere else - will hopefully find it useful.
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||
|
||||
Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with `Citus <https://www.citusdata.com>`__. Please check `Citus support <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ page for more information.
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
|
||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
|
||||
|
||||
@@ -49,7 +49,7 @@ We report new releases information `here <https://github.com/zalando/patroni/rel
|
||||
Community
|
||||
=========
|
||||
|
||||
There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://postgresteam.slack.com/>`__. If you're using Patroni, or just interested, please join us.
|
||||
There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__. If you're using Patroni, or just interested, please join us.
|
||||
|
||||
===================================
|
||||
Technical Requirements/Installation
|
||||
@@ -74,27 +74,11 @@ There are a few options available:
|
||||
|
||||
::
|
||||
|
||||
sudo apt-get install python-psycopg2 # install python2 psycopg2 module on Debian/Ubuntu
|
||||
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu
|
||||
sudo yum install python-psycopg2 # install python2 psycopg2 on RedHat/Fedora/CentOS
|
||||
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
|
||||
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
|
||||
|
||||
2. Install psycopg2 from the binary package
|
||||
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the list of dependencies when installing Patroni with pip (see below).
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg2-binary
|
||||
|
||||
3. Install psycopg2 from source
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg2>=2.5.4
|
||||
|
||||
4. Use psycopg 3.0 instead of psycopg2
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg[binary]
|
||||
|
||||
**General installation for pip**
|
||||
|
||||
@@ -119,13 +103,21 @@ kubernetes
|
||||
raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto` in order to use AWS callbacks
|
||||
`boto3` in order to use AWS callbacks
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg3
|
||||
`psycopg[binary]>=3.0.0` module
|
||||
psycopg2
|
||||
`psycopg2>=2.5.4` module
|
||||
psycopg2-binary
|
||||
`psycopg2-binary` module
|
||||
|
||||
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
|
||||
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
|
||||
|
||||
::
|
||||
|
||||
pip install patroni[etcd,aws]
|
||||
pip install patroni[psycopg3,etcd3,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ networks:
|
||||
|
||||
services:
|
||||
etcd1: &etcd
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
environment:
|
||||
ETCDCTL_API: 3
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
|
||||
ETCD_INITIAL_CLUSTER_STATE: new
|
||||
ETCD_INITIAL_CLUSTER_TOKEN: tutorial
|
||||
ETCD_UNSUPPORTED_ARCH: arm64
|
||||
container_name: demo-etcd1
|
||||
hostname: etcd1
|
||||
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||
@@ -42,7 +43,7 @@ services:
|
||||
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||
|
||||
haproxy:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: haproxy
|
||||
@@ -64,7 +65,7 @@ services:
|
||||
PGSSLROOTCERT: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
|
||||
coord1:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: coord1
|
||||
@@ -75,7 +76,7 @@ services:
|
||||
PATRONI_CITUS_GROUP: 0
|
||||
|
||||
coord2:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: coord2
|
||||
@@ -85,7 +86,7 @@ services:
|
||||
PATRONI_NAME: coord2
|
||||
|
||||
coord3:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: coord3
|
||||
@@ -96,7 +97,7 @@ services:
|
||||
|
||||
|
||||
work1-1:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: work1-1
|
||||
@@ -107,7 +108,7 @@ services:
|
||||
PATRONI_CITUS_GROUP: 1
|
||||
|
||||
work1-2:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: work1-2
|
||||
@@ -118,7 +119,7 @@ services:
|
||||
|
||||
|
||||
work2-1:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: work2-1
|
||||
@@ -129,7 +130,7 @@ services:
|
||||
PATRONI_CITUS_GROUP: 2
|
||||
|
||||
work2-2:
|
||||
image: patroni-citus
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: work2-2
|
||||
|
||||
+6
-5
@@ -14,7 +14,7 @@ networks:
|
||||
|
||||
services:
|
||||
etcd1: &etcd
|
||||
image: patroni
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
networks: [ demo ]
|
||||
environment:
|
||||
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
|
||||
ETCD_INITIAL_CLUSTER_STATE: new
|
||||
ETCD_INITIAL_CLUSTER_TOKEN: tutorial
|
||||
ETCD_UNSUPPORTED_ARCH: arm64
|
||||
container_name: demo-etcd1
|
||||
hostname: etcd1
|
||||
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||
@@ -39,7 +40,7 @@ services:
|
||||
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||
|
||||
haproxy:
|
||||
image: patroni
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: haproxy
|
||||
@@ -54,7 +55,7 @@ services:
|
||||
PATRONI_SCOPE: demo
|
||||
|
||||
patroni1:
|
||||
image: patroni
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: patroni1
|
||||
@@ -64,7 +65,7 @@ services:
|
||||
PATRONI_NAME: patroni1
|
||||
|
||||
patroni2:
|
||||
image: patroni
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: patroni2
|
||||
@@ -74,7 +75,7 @@ services:
|
||||
PATRONI_NAME: patroni2
|
||||
|
||||
patroni3:
|
||||
image: patroni
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: patroni3
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Example session:
|
||||
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
|
||||
+---------+----------+------------+--------+---------+----+-----------+
|
||||
|
||||
postgres@patroni1:~$ etcdctl ls --recursive --sort -p /service/demo
|
||||
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
|
||||
/service/demo/config
|
||||
/service/demo/initialize
|
||||
/service/demo/leader
|
||||
|
||||
+7
-57
@@ -1,62 +1,12 @@
|
||||
.. _contributing:
|
||||
|
||||
Contributing guidelines
|
||||
=======================
|
||||
Contributing
|
||||
============
|
||||
|
||||
Wanna contribute to Patroni? Yay - here is how!
|
||||
Resources and information for developers can be found in the pages below.
|
||||
|
||||
Chatting
|
||||
--------
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://postgresteam.slack.com/>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
Requirements for running behave tests:
|
||||
|
||||
1. PostgreSQL packages need to be installed.
|
||||
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify pip3.
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
After you have all dependencies installed, you can run the various test suites:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify python3.
|
||||
|
||||
# Run flake8 to check syntax and formatting:
|
||||
python setup.py flake8
|
||||
|
||||
# Run the pytest suite in tests/:
|
||||
python setup.py test
|
||||
|
||||
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
|
||||
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
|
||||
DCS=raft python -m behave
|
||||
|
||||
Reporting issues
|
||||
----------------
|
||||
|
||||
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
|
||||
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Contributing a pull request
|
||||
---------------------------
|
||||
|
||||
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
|
||||
2) Do a fork, develop and test your code changes.
|
||||
3) Include documentation
|
||||
4) Submit a pull request.
|
||||
|
||||
You'll get feedback about your pull request as soon as possible.
|
||||
|
||||
Happy Patroni hacking ;-)
|
||||
contributing_guidelines
|
||||
Patroni API docs<modules/modules>
|
||||
|
||||
+31
-18
@@ -24,15 +24,6 @@ Log
|
||||
- **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling.
|
||||
- **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"``
|
||||
|
||||
Bootstrap configuration
|
||||
-----------------------
|
||||
It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables:
|
||||
|
||||
- **PATRONI\_<username>\_PASSWORD='<password>'**
|
||||
- **PATRONI\_<username>\_OPTIONS='list,of,options'**
|
||||
|
||||
Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases.
|
||||
|
||||
Citus
|
||||
-----
|
||||
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
|
||||
@@ -112,11 +103,16 @@ 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 ``master``.
|
||||
- **PATRONI\_KUBERNETES\_TMP\_ROLE\_LABEL**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
|
||||
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
|
||||
- **PATRONI\_KUBERNETES\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
- **PATRONI\_RETRIABLE\_HTTP\_CODES**: (optional) list of HTTP status codes from K8s API to retry on. By default Patroni is retrying on ``500``, ``503``, and ``504``, or if K8s API response has ``retry-after`` HTTP header.
|
||||
|
||||
Raft (deprecated)
|
||||
-----------------
|
||||
@@ -134,7 +130,14 @@ PostgreSQL
|
||||
- **PATRONI\_POSTGRESQL\_PROXY\_ADDRESS**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
|
||||
- **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
|
||||
- **PATRONI\_POSTGRESQL\_CONFIG\_DIR**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
|
||||
- **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, initdb, pg_controldata, pg_basebackup, postgres, pg_isready, pg_rewind) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_PG\_CTL**: (optional) Custom name for ``pg_ctl`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_INITDB**: (optional) Custom name for ``initdb`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_PG\_CONTROLDATA**: (optional) Custom name for ``pg_controldata`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_PG\_BASEBACKUP**: (optional) Custom name for ``pg_basebackup`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_POSTGRES**: (optional) Custom name for ``postgres`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_IS\_READY**: (optional) Custom name for ``pg_isready`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_BIN\_PG\_REWIND**: (optional) Custom name for ``pg_rewind`` binary.
|
||||
- **PATRONI\_POSTGRESQL\_PGPASS**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni.
|
||||
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
|
||||
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
|
||||
@@ -158,8 +161,8 @@ PostgreSQL
|
||||
- **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
|
||||
- **PATRONI\_REWIND\_PASSWORD**: password for the user for ``pg_rewind``; the user will be created during initialization.
|
||||
- **PATRONI\_REWIND\_USERNAME**: (optional) name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
|
||||
- **PATRONI\_REWIND\_PASSWORD**: (optional) password for the user for ``pg_rewind``; the user will be created during initialization.
|
||||
- **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``.
|
||||
@@ -186,11 +189,21 @@ REST API
|
||||
- **PATRONI\_RESTAPI\_ALLOWLIST\_INCLUDE\_MEMBERS**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
- **PATRONI\_RESTAPI\_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 :ref:`patronictl` will use the value provided for REST API "username" parameter.
|
||||
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
|
||||
- **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
|
||||
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
|
||||
- **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format.
|
||||
- **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format.
|
||||
- **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile.
|
||||
|
||||
+4
-84
@@ -4,7 +4,7 @@
|
||||
Introduction
|
||||
============
|
||||
|
||||
Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
|
||||
Patroni is a template for high availability (HA) PostgreSQL solutions using Python. Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
|
||||
|
||||
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
|
||||
|
||||
@@ -25,83 +25,7 @@ We report new releases information :ref:`here <releases>`.
|
||||
Technical Requirements/Installation
|
||||
-----------------------------------
|
||||
|
||||
**Pre-requirements for Mac OS**
|
||||
|
||||
To install requirements on a Mac, run the following:
|
||||
|
||||
::
|
||||
|
||||
brew install postgresql etcd haproxy libyaml python
|
||||
|
||||
.. _psycopg2_install_options:
|
||||
|
||||
**Psycopg**
|
||||
|
||||
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
|
||||
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
|
||||
|
||||
There are a few options available:
|
||||
|
||||
1. Use the package manager from your distro
|
||||
|
||||
::
|
||||
|
||||
sudo apt-get install python-psycopg2 # install python2 psycopg2 module on Debian/Ubuntu
|
||||
sudo apt-get install python3-psycopg2 # install python3 psycopg2 module on Debian/Ubuntu
|
||||
sudo yum install python-psycopg2 # install python2 psycopg2 on RedHat/Fedora/CentOS
|
||||
|
||||
2. Install psycopg2 from the binary package
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg2-binary
|
||||
|
||||
3. Install psycopg2 from source
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg2>=2.5.4
|
||||
|
||||
4. Use psycopg 3.0 instead of psycopg2
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg[binary]>=3.0.0
|
||||
|
||||
**General installation for pip**
|
||||
|
||||
Patroni can be installed with pip:
|
||||
|
||||
::
|
||||
|
||||
pip install patroni[dependencies]
|
||||
|
||||
where dependencies can be either empty, or consist of one or more of the following:
|
||||
|
||||
etcd or etcd3
|
||||
`python-etcd` module in order to use Etcd as Distributed Configuration Store (DCS)
|
||||
consul
|
||||
`python-consul` module in order to use Consul as DCS
|
||||
zookeeper
|
||||
`kazoo` module in order to use Zookeeper as DCS
|
||||
exhibitor
|
||||
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
|
||||
kubernetes
|
||||
`kubernetes` module in order to use Kubernetes as DCS in Patroni
|
||||
raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto` in order to use AWS callbacks
|
||||
|
||||
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
|
||||
|
||||
::
|
||||
|
||||
pip install patroni[etcd,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
|
||||
independently of Patroni.
|
||||
|
||||
Go :ref:`here <installation>` for guidance on installing and upgrading Patroni on various platforms.
|
||||
|
||||
.. _running_configuring:
|
||||
|
||||
@@ -145,7 +69,7 @@ run:
|
||||
YAML Configuration
|
||||
------------------
|
||||
|
||||
Go :ref:`here <settings>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
||||
Go :ref:`here <yaml_configuration>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
||||
|
||||
|
||||
Environment Configuration
|
||||
@@ -165,10 +89,6 @@ Applications Should Not Use Superusers
|
||||
|
||||
When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
||||
:target: https://travis-ci.org/zalando/patroni
|
||||
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/zalando/patroni?branch=master
|
||||
|
||||
Testing Your HA Solution
|
||||
--------------------------------------
|
||||
@@ -179,7 +99,7 @@ That said, here are some pieces of your infrastructure you should be sure to tes
|
||||
* Network (the network in front of your system as well as the NICs [physical or virtual] themselves)
|
||||
* Disk IO
|
||||
* file limits (nofile in Linux)
|
||||
* RAM. Even if you have oomkiller turned off as suggested, the unavailability of RAM could cause issues.
|
||||
* RAM. Even if you have oomkiller turned off, the unavailability of RAM could cause issues.
|
||||
* CPU
|
||||
* Virtualization Contention (overcommitting the hypervisor)
|
||||
* Any cgroup limitation (likely to be related to the above)
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
.. _settings:
|
||||
|
||||
===========================
|
||||
YAML Configuration Settings
|
||||
===========================
|
||||
|
||||
.. _dynamic_configuration_settings:
|
||||
|
||||
Dynamic configuration settings
|
||||
------------------------------
|
||||
|
||||
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes. Some parameters, like **loop_wait**, **ttl**, **postgresql.parameters.max_connections**, **postgresql.parameters.max_worker_processes** and so on could be set only in the dynamic configuration. Some other parameters like **postgresql.listen**, **postgresql.data_dir** could be set only locally, i.e. in the Patroni config file or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration. In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
|
||||
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
|
||||
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
|
||||
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
|
||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
|
||||
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
||||
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
|
||||
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
|
||||
- **postgresql**:
|
||||
- **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
|
||||
- **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
|
||||
- **parameters**: list of configuration settings for Postgres.
|
||||
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
|
||||
- **host**: an address of remote node
|
||||
- **port**: a port of remote node
|
||||
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
|
||||
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **archive\_cleanup\_command**: cleanup command for standby leader
|
||||
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
- **my_slot_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
|
||||
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
|
||||
- **database**: the database name where logical slots should be created.
|
||||
- **plugin**: the plugin name for the logical slot.
|
||||
- **ignore_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
|
||||
- **name**: the name of the replication slot.
|
||||
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
|
||||
- **database**: the database name (when matching a ``logical`` slot).
|
||||
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
|
||||
|
||||
Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
slots:
|
||||
permanent_logical_slot_name:
|
||||
type: logical
|
||||
database: my_db
|
||||
plugin: test_decoding
|
||||
permanent_physical_slot_name:
|
||||
type: physical
|
||||
...
|
||||
ignore_slots:
|
||||
- name: ignored_logical_slot_name
|
||||
type: logical
|
||||
database: my_db
|
||||
plugin: test_decoding
|
||||
- name: ignored_physical_slot_name
|
||||
type: physical
|
||||
...
|
||||
|
||||
Global/Universal
|
||||
----------------
|
||||
- **name**: the name of the host. Must be unique for the cluster.
|
||||
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||
- **scope**: cluster name
|
||||
|
||||
Log
|
||||
---
|
||||
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
|
||||
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
|
||||
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
|
||||
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
|
||||
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
|
||||
- **file\_num**: The number of application logs to retain.
|
||||
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
|
||||
- **loggers**: This section allows redefining logging level per python module
|
||||
- **patroni.postmaster: WARNING**
|
||||
- **urllib3: DEBUG**
|
||||
|
||||
.. _bootstrap_settings:
|
||||
|
||||
Bootstrap configuration
|
||||
-----------------------
|
||||
- **bootstrap**:
|
||||
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing of new cluster. The global dynamic configuration for the cluster. Under the ``bootstrap.dcs`` you can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration_settings>` and after Patroni initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store. All later changes of ``bootstrap.dcs`` will not take any effect! If you want to change them please use either ``patronictl edit-config`` or Patroni :ref:`REST API <rest_api>`.
|
||||
- **method**: custom script to use for bootstrapping this cluster.
|
||||
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
|
||||
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
|
||||
parameter is present in the configuration file.
|
||||
- **initdb**: List options to be passed on to initdb.
|
||||
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
|
||||
- **- encoding: UTF8**: default encoding for new databases.
|
||||
- **- locale: UTF8**: default locale for new databases.
|
||||
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
|
||||
- **- host all all 0.0.0.0/0 md5**.
|
||||
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
||||
- **users**: Some additional users which need to be created after initializing new cluster
|
||||
- **admin**: the name of user
|
||||
- **password: zalando**:
|
||||
- **options**: list of options for CREATE USER statement
|
||||
- **- createrole**
|
||||
- **- createdb**
|
||||
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
|
||||
|
||||
.. _citus_settings:
|
||||
|
||||
Citus
|
||||
-----
|
||||
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
|
||||
|
||||
- **group**: the Citus group id, integer. Use ``0`` for coordinator and ``1``, ``2``, etc... for workers
|
||||
- **database**: the database where ``citus`` extension should be created. Must be the same on the coordinator and all workers. Currently only one database is supported.
|
||||
|
||||
.. _consul_settings:
|
||||
|
||||
Consul
|
||||
------
|
||||
Most of the parameters are optional, but you have to specify one of the **host** or **url**
|
||||
|
||||
- **host**: the host:port for the Consul local agent.
|
||||
- **url**: url for the Consul local agent, in format: http(s)://host:port.
|
||||
- **port**: (optional) Consul port.
|
||||
- **scheme**: (optional) **http** or **https**, defaults to **http**.
|
||||
- **token**: (optional) ACL token.
|
||||
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests.
|
||||
- **cacert**: (optional) The ca certificate. If present it will enable validation.
|
||||
- **cert**: (optional) file with the client certificate.
|
||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
|
||||
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
|
||||
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
|
||||
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**.
|
||||
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``primary``/``replica``/``standby-leader``). By default an empty list is used.
|
||||
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
|
||||
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
|
||||
|
||||
The ``token`` needs to have the following ACL permissions:
|
||||
|
||||
::
|
||||
|
||||
service_prefix "${scope}" {
|
||||
policy = "write"
|
||||
}
|
||||
key_prefix "${namespace}/${scope}" {
|
||||
policy = "write"
|
||||
}
|
||||
session_prefix "" {
|
||||
policy = "write"
|
||||
}
|
||||
|
||||
Etcd
|
||||
----
|
||||
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
|
||||
|
||||
- **host**: the host:port for the etcd endpoint.
|
||||
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
|
||||
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
|
||||
- **url**: url for the etcd.
|
||||
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
|
||||
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
|
||||
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **username**: (optional) username for etcd authentication.
|
||||
- **password**: (optional) password for etcd authentication.
|
||||
- **cacert**: (optional) The ca certificate. If present it will enable validation.
|
||||
- **cert**: (optional) file with the client certificate.
|
||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||
|
||||
Etcdv3
|
||||
------
|
||||
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
|
||||
|
||||
.. warning::
|
||||
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
|
||||
|
||||
|
||||
ZooKeeper
|
||||
----------
|
||||
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
|
||||
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
|
||||
- **cacert**: (optional) The CA certificate. If present it will enable validation.
|
||||
- **cert**: (optional) File with the client certificate.
|
||||
- **key**: (optional) File with the client key.
|
||||
- **key_password**: (optional) The client key password.
|
||||
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||
|
||||
.. note::
|
||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||
|
||||
|
||||
Exhibitor
|
||||
---------
|
||||
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
|
||||
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor.
|
||||
- **port**: Exhibitor port.
|
||||
|
||||
.. _kubernetes_settings:
|
||||
|
||||
Kubernetes
|
||||
----------
|
||||
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
|
||||
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
|
||||
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
|
||||
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
||||
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
|
||||
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
|
||||
|
||||
.. _raft_settings:
|
||||
|
||||
Raft (deprecated)
|
||||
-----------------
|
||||
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
|
||||
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
|
||||
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
|
||||
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
|
||||
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
|
||||
|
||||
Short FAQ about Raft implementation
|
||||
|
||||
- Q: How to list all the nodes providing consensus?
|
||||
|
||||
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
|
||||
|
||||
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
|
||||
|
||||
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
|
||||
|
||||
- Q: Where to get the ``syncobj_admin`` utility?
|
||||
|
||||
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
|
||||
|
||||
- Q: it is possible to run Patroni node without adding in to the consensus?
|
||||
|
||||
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
|
||||
|
||||
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
|
||||
|
||||
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
|
||||
|
||||
|
||||
.. _postgresql_settings:
|
||||
|
||||
PostgreSQL
|
||||
----------
|
||||
- **postgresql**:
|
||||
- **authentication**:
|
||||
- **superuser**:
|
||||
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
|
||||
- **password**: password for the superuser, set during initialization (initdb).
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **replication**:
|
||||
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
|
||||
- **password**: replication password; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **rewind**:
|
||||
- **username**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
|
||||
- **password**: password for the user for ``pg_rewind``; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
|
||||
- **on\_reload**: run this script when configuration reload is triggered.
|
||||
- **on\_restart**: run this script when the postgres restarts (without changing role).
|
||||
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
|
||||
- **on\_start**: run this script when the postgres starts.
|
||||
- **on\_stop**: run this script when the postgres stops.
|
||||
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
|
||||
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
|
||||
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
|
||||
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
|
||||
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
|
||||
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
|
||||
- **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
|
||||
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. This parameter has higher priority than ``bootstrap.pg_hba``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_hba.conf``.
|
||||
- **- host all all 0.0.0.0/0 md5**.
|
||||
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
||||
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_ident.conf``.
|
||||
- **- mapname1 systemname1 pguser1**.
|
||||
- **- mapname1 systemname2 pguser2**.
|
||||
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
|
||||
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
|
||||
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
|
||||
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
|
||||
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
||||
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
|
||||
|
||||
.. _restapi_settings:
|
||||
|
||||
REST API
|
||||
--------
|
||||
- **restapi**:
|
||||
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
|
||||
|
||||
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
|
||||
|
||||
- **authentication**: (optional)
|
||||
- **username**: Basic-auth username to protect unsafe REST API endpoints.
|
||||
- **password**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
|
||||
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
|
||||
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
|
||||
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
|
||||
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
|
||||
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
|
||||
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
|
||||
Here is an example of both **http_extra_headers** and **https_extra_headers**:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
restapi:
|
||||
listen: <listen>
|
||||
connect_address: <connect_address>
|
||||
authentication:
|
||||
username: <username>
|
||||
password: <password>
|
||||
http_extra_headers:
|
||||
'X-Frame-Options': 'SAMEORIGIN'
|
||||
'X-XSS-Protection': '1; mode=block'
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
cafile: <ca file>
|
||||
certfile: <cert>
|
||||
keyfile: <key>
|
||||
https_extra_headers:
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
|
||||
|
||||
.. _patronictl_settings:
|
||||
|
||||
CTL
|
||||
---
|
||||
- **ctl**: (optional)
|
||||
- **insecure**: Allow connections to REST API without verifying SSL certs.
|
||||
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
|
||||
- **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
|
||||
- **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
|
||||
- **keyfile\_password**: Specifies a password for decrypting the keyfile. If not provided patronictl will use the value provided for REST API "keyfile\_password" parameter.
|
||||
|
||||
Watchdog
|
||||
--------
|
||||
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
|
||||
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
|
||||
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
|
||||
|
||||
.. _tags_settings:
|
||||
|
||||
Tags
|
||||
----
|
||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
|
||||
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
|
||||
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
||||
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||
|
||||
In addition to these predefined tags, you can also add your own ones:
|
||||
|
||||
- **key1**: ``true``
|
||||
- **key2**: ``false``
|
||||
- **key3**: ``1.4``
|
||||
- **key4**: ``"RandomString"``
|
||||
|
||||
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
@@ -0,0 +1 @@
|
||||
<mxfile host="app.diagrams.net" modified="2023-03-13T14:29:21.924Z" agent="5.0 (X11; Ubuntu)" etag="sukwsRuBYbiX8e-LLYnw" version="21.0.6" type="device"><diagram name="Page-1" id="Xu3tU9JEMeQEUPilRV_D">7Vxtb9s2EP41BrYPNfTmt4+Jk2bFOixrihXYF4O2aEkNLaoUZTv99SMlUhZF+i2RE8dVEsDiiTxKd88deXeMO+54sb4jIAn/wj5EHcfy1x33puM4tud67INTngrKkLc4ISCRLzptCA/RTyiIlqBmkQ9TpSPFGNEoUYkzHMdwRhUaIASv1G5zjNRZExBAjfAwA0infot8Ggqq3R9tbvwBoyCk8v0GxY0FkJ3Fm6Qh8PGqQnJvO+6YYEyLq8V6DBEXnpRLMe7jlrvlgxEY00MG/L3474v99d/Hb+jnn596d99vsjj7ILgsAcrEC9+MhYJS+iSFkOAoprkge9fsj80ztjo9dmfMW12nVyPU2wOVYOstzkMl1NsDlWDX2du1+e36A1YIWkthb9XmtyoPyP7ca5xRFMVwXELOYsSAAD9iqhhjhAmjxThm0rsO6QKxls0uV2FE4UMCZlyqK2YujDbHMRWgtx3ZFoLnXBmsKWBzEcEj1wQkt0tYKKTogxBI0mhajiJwlpE0WsIvMC2YcyoDYMKvF+uA22oXrFKvGxCcJfnjf2JzGe9O2OVkhnDmcyaU4EcoX7LjuOz3Iwfc9TxCqPbyS0hoxGzpCkUB500xnwqIFoJzyjkyiURx8Dlv3biWkIJpCh+kIfTF6+j4l2Bms8J1hSTs4Q7iBaTkiXURd3vSNoVz8kRztbF0T9LCipF7chwQ3iUoWW8MkF0IGzzCHh3NHu8Bk3gcaTZpELemm95VfzzsVwVnb9VKHXk1HZSsTCiugFzXyk6/c7CqbHvAzXq3shyrpyur7Ni4slxdWXd8TJxSEDP5OH3EAT4l7CrIoc7o/vRJ02X6COksFII3epdtFrHF6xxmpYw+z3/qpiUR8hlMIbrHaUSj3DdMMaV4sdewZ5D7KBUX+xwdSJPibefRGvrbvBWBKc7IDBa+ivm51OS1/OlE6mAiRX5CZI5UJzLQcdk3+JD+qVDpHYtKAoHPOhCYIKbTFpyvB04u+YmU+wkR2lMRar81RHstRFuIKhB13DODaF+D6O3X8Q0PNFGWcu04lh4nKTisKE8BR76BSmthgIoqK/8x4bDEWz0k61pWHmR1+24t+BLxVY06MlKLOK3Wc7SF8SAfze4bmNg1mjOs9c0Dqb12olmE2XDqWH/MppDEkIm5GxVIT2R4wxTkn8zPDlUQu44O4qEpnBieCMQDDcQtZFvIViHbPzPEDjWAQj+AcqHDhIY4wDFAtxvqNcFZ7JdY3fT5jLmkczR/h5Q+ieUTZBS/JGYtVtAd/URmkAISwF38xBLDX3CnqghEgEZLNSFpkrwYes/trLK01r2S56ksigcVo2r6Kx/j+SodtU6odUI7nRDT23l5IVl8eNduaPBWbuhlorcvQPT9A0U/Oi/R6475ckU/OC/R66nkG74WFHkQtujFil76PzJeNSyWxA9iTbziYiQwF6nsIPMn7JMtgqPiChWUjwVb2aEt+bUlv03Jb4ZJggmgcOIDCiblPmJ7iel05T+9itVQ+c9Ttx1vX/2zDbn7yyz/ucfqyrbPrfpnt1nsS89iH49Sr16lfvNEtq0nAVuY/uIwdZzzg+lQg6lWcNFDwzZxdFGJo+P97blVXOw229mC9p3VXJyzznYK8e5N/JSnw/dlfuRKc+l1lzIebl1R64reS+XFgNF36IvkLuANfNHLpO9ehPSHB0pfyvFcpO/9UtKXRvL60n+kftr/Z/jtGi7DW39l/0juoeEwv8yM+NGyUkepJUsq9RRDv5xkKNzwhMKHIk/Pyzb9ZN3ZUrY5fjqVxGc65BFsd88zHMzIa4pRrylG+8R7MKNBU4yGTTEaNcSIeYCGGNlNMXKaYtQUsp1tyL6vWGXBTDPWX5qsuKV6BJIHDPJfax11bapvk+cIr2YhILTLq5JTkPIV0FSROtmG2altmAc9bb/slrnV6o6510Di1Lhu6QfVj1x87KYMbSsjY73hQLic2as0xigh0QJw9e+UhnHhf4aVMXRT1bT2BqqLyPeLHSY/UAA2Jw3UQJ5HxXxTKQ4d5Far5ABEkcdQr65UWdjW95RVuUGt2OHqUe7IFOWeymbPZKNvOFO1a2u8d0fvntWGXi/PS2MJ3WearjVDIE2VQTSiCL6Cw9l6BixntBKo5axiTBYAGZk9UALBIooD1u1LUWbME1iO9RtIn+JZSHCMs/T3ildRD4nt80FcsltcEEdFnjY7nR/SEb7L+jT/UX6JiJikU/2eDpNfsbqW5wwV1yJt4Lm5Y9kFz+cpPDItzJqbbxMpum++k8W9/R8=</diagram></mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1 @@
|
||||
<mxfile host="app.diagrams.net" modified="2023-03-13T14:25:32.295Z" agent="5.0 (X11; Ubuntu)" etag="EcVEbU6F-AyuIGXoP3hl" version="21.0.6" type="device"><diagram id="SVgELWPNXIlR7V7eDs_m" name="Page-1">7Vtbc5s4FP41frQHgY3tx9hO2u5kZ9Km7c70xSODDGxlxAoRO/31K4FkgwS2k+DGTZxmpuggjsS5fOci0nGmq80HCpPwb+Ij3LEtf9NxZh3bBn2nz/8TlMeCMhyOCkJAI19O2hHuo19IEi1JzSIfpZWJjBDMoqRK9EgcI49VaJBSsq5OWxJcXTWBATII9x7EJvWfyGehpAJ3vLvxEUVBKJce2cPixgqqyfJN0hD6ZF0iOdcdZ0oJYcXVajNFWAhPyaV47qbh7nZjFMXsmAf8b7fdj9cAuzc/wOTz92/W3Y+vXafg8gBxJl94NrXlftmjEkJCopjlghxM+C9fZ2p1BvzOVIx69kAj6ONhlQDMkeBRJejjYZUAdPZAWx/oGywRjFGFvaWtb5U2yH+dCckYjmI03ZqcxYkBhX7EVTElmFBOi0nMpTcJ2QrzEeCX6zBi6D6BnpDqmrsLpy1JzKTRA1uNpeAFV27WDPK1qOSRawLR6wdUKKSYgzFM0mixfYoiL6Np9IC+oLRgLqjcABNxvdoEwld7cJ32ewElWZJv/xNfq/bunF/OPUwyXzBhlPxE6iU7tsP/3QiDmywjjLWXf0CURdyXrnAUCN6MiKWgHGG0ZIIjl0gUB7f5aOZYUgp1S/gwDZEvX8e0f+kSYlW0KZGkP3xAZIUYfeRT5N2+JX3zUY2L4Xrn6Y5y37Dk5I56Dkp0Cbasdw7IL6QPPsEf3b7hkHeQizyODKeskbehnMGVOx25ZcmBRrXopqcpYcuqzoxLVm6qZS/wHK0roOvKNnQF6nQFHPtEunKAoavrr9OZCEY4S7mX8itgqC39iZgXShmX5Fax7VzGqQYVJX13hAmKnzqlL/MfBRYl2O5ZVg7EPdfRAFpisEYd11ILLNdmjhsYD/On+f0aJkCj2SNtbg62ylhv4QLhO5JGLMpxakEYI6sSnHhIIGOjeevo9zNbIBojLuZelCPfJFEQyBXkn8yM7aoZ25aJOaMaM+6PXm7F6ch2HA96nxfjq1/hp+zT9C+vaxpxx3axAGY/euCXAcuduiAtqE7ha9bMy0klq3f/y0Sak2NKtwhJV3yCm2yKh+TtFy1XJYmVjtkCcA7s4WhG/bYYDdpidEi8RzMatsVo1BajcUuMuIu3xAi0xchui1Fblm03WfZdySsLZoazvmtyBZb0NCCP2qqktKu5gB6rlpisvRBS1vMhgwuYooY8rCEaHRvImqNWvxq1RMWlJ8rD3sAMW4MWEuXasGXvD1tHQEhbftbI6O6DeIk4ZTDmOnqatZzZq7TGKKHRCgr175VGbdx/hpNxL2BVzzqYLK4i3xeP8xqavw7c1dTVZFpkpjBjRJbXudNW8nBZkdUUaaWS3+6f0mfBoOKzwDEzzXFdpnmq2tYsbWeiBig0yYuduNaycpjrylpI2FZCUZON8uJnXLWOgm2DeVzaWu+6reURmhAKGZqLqDrfQkJzF+V0LS6zUdNai+vMOlxgbHa46gLlLgZQBHk5blGUYC7p/Q2VWhtuaic22PZxLc5yo6WitIMNi/0mszXDkosdcieYJsXbLqMN8pt8gkc0klEPFR4hAlydb/iLuVLFXEh+ruR+dGuv/1Qb3UYmaaSu2dpza2zUPZWJqu1cGnvvt7H3dCN+xcZe/VGCWSK9zaOEp6vq8LHPbz5KAGYXdjZ1LgnrJWF9F+ewQ/fsstThJUt921kqeLKVnl2a6hpWhvwAKZETykISkBji6x11woEk9rdWsJtzS4R+cz3+ixh7lIoUPa5nBWUl3kKZ+95CSlFsfa8WKMKQcSytYEydUOWjdwKid9rrAg1kOMxUeTBIA8TkY5putvt4gbrMfOxSVbyzquIZoHN2ZYX7TsqKZ+jq7D5RskfnESLUl7s5wu6T+fj3BIOBFsoNDRRh63SxwEwvVUYZOnlEgGmxE3XKwSKGUQsHc219pnDwqLDxQCdntJbmL1jFhK4grmV2/xh7IccWkqV84pcix8sRvemI55lH9ULqDadEwhbzaNI52UnikYikvLnmrFB+/i5X6ZS/MK9Dqi4P7mPHqfiA+hLsua6lppDlMkUn8Rp7/Ieh2fA3odn4ldFM2WUDmh0GE/MrrBNCYFuffLXH6ALKLZD/DAhXp58vhnCO4CN3WEVw59wR3DF72q+J4IebE9aRUK/+FqA9qH9Za6j/h8n52JCqFHIuch68VTm33pVrkDMf7v4EsoCZ3R+SOtf/Aw==</diagram></mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+23
-16
@@ -12,9 +12,10 @@ TL;DR
|
||||
|
||||
There are only a few simple rules you need to follow:
|
||||
|
||||
1. Citus extension must be available on all nodes. Absolute minimum supported
|
||||
Citus version is 10.0, but, to take all benefits from transparent
|
||||
switchovers and restarts of workers we recommend using at least Citus 11.2.
|
||||
1. `Citus <https://github.com/citusdata/citus>`__ database extension to
|
||||
PostgreSQL must be available on all nodes. Absolute minimum supported Citus
|
||||
version is 10.0, but, to take all benefits from transparent switchovers and
|
||||
restarts of workers we recommend using at least Citus 11.2.
|
||||
2. Cluster name (``scope``) must be the same for all Citus nodes!
|
||||
3. Superuser credentials must be the same on coordinator and all worker
|
||||
nodes, and ``pg_hba.conf`` should allow superuser access between all nodes.
|
||||
@@ -37,24 +38,30 @@ After that you just need to start Patroni and it will handle the rest:
|
||||
2. If ``max_prepared_transactions`` isn't explicitly set in the global
|
||||
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
|
||||
automatically set it to ``2*max_connections``.
|
||||
3. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
|
||||
4. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
|
||||
3. The ``citus.local_hostname`` GUC value will be adjusted from ``localhost`` to the
|
||||
value that Patroni is using in order to connect to the local PostgreSQL
|
||||
instance. The value sometimes should be different from the ``localhost``
|
||||
because PostgreSQL might be not listening on it.
|
||||
4. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
|
||||
5. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
|
||||
table to allow cross-node communication. Don't forget to update them if
|
||||
later you decide to change superuser username/password/sslcert/sslkey!
|
||||
5. The coordinator primary node will automatically discover worker primary
|
||||
6. The coordinator primary node will automatically discover worker primary
|
||||
nodes and add them to the ``pg_dist_node`` table using the
|
||||
``citus_add_node()`` function.
|
||||
6. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
|
||||
7. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
|
||||
on the coordinator or worker clusters occurs.
|
||||
|
||||
patronictl
|
||||
----------
|
||||
|
||||
Coordinator and worker clusters are physically different PostgreSQL/Patroni
|
||||
clusters that are just logically groupped together using Citus. Therefore in
|
||||
most cases it is not possible to manage them as a single entity.
|
||||
clusters that are just logically groupped together using the
|
||||
`Citus <https://github.com/citusdata/citus>`__ database extension to
|
||||
PostgreSQL. Therefore in most cases it is not possible to manage them as a
|
||||
single entity.
|
||||
|
||||
It results in two major differences in ``patronictl`` behaviour when
|
||||
It results in two major differences in :ref:`patronictl` behaviour when
|
||||
``patroni.yaml`` has the ``citus`` section comparing with the usual:
|
||||
|
||||
1. The ``list`` and the ``topology`` by default output all members of the Citus
|
||||
@@ -62,12 +69,12 @@ It results in two major differences in ``patronictl`` behaviour when
|
||||
which Citus group they belong to.
|
||||
2. For all ``patronictl`` commands the new option is introduced, named
|
||||
``--group``. For some commands the default value for the group might be
|
||||
taken from the ``patroni.yaml``. For example, ``patronictl pause`` will
|
||||
taken from the ``patroni.yaml``. For example, :ref:`patronictl_pause` will
|
||||
enable the maintenance mode by default for the ``group`` that is set in the
|
||||
``citus`` section, but for example for ``patronictl switchover`` or
|
||||
``patronictl remove`` the group must be explicitly specified.
|
||||
``citus`` section, but for example for :ref:`patronictl_switchover` or
|
||||
:ref:`patronictl_remove` the group must be explicitly specified.
|
||||
|
||||
An example of ``patronictl list`` output for the Citus cluster::
|
||||
An example of :ref:`patronictl_list` output for the Citus cluster::
|
||||
|
||||
postgres@coord1:~$ patronictl list demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
@@ -112,7 +119,7 @@ the coordinator for the shards hosted on a worker node. The switchover then
|
||||
happens while the traffic is kept on the coordinator, and resumes as soon as a
|
||||
new primary worker node is ready to accept read-write queries.
|
||||
|
||||
An example of ``patronictl switchover`` on the worker cluster::
|
||||
An example of :ref:`patronictl_switchover` on the worker cluster::
|
||||
|
||||
postgres@coord1:~$ patronictl switchover demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
@@ -340,7 +347,7 @@ Citus upgrades and PostgreSQL major upgrades
|
||||
|
||||
First, please read about upgrading Citus version in the `documentation`__.
|
||||
There is one minor change in the process. When executing upgrade, you have to
|
||||
use ``patronictl restart`` instead of ``systemctl restart`` to restart
|
||||
use :ref:`patronictl_restart` instead of ``systemctl restart`` to restart
|
||||
PostgreSQL.
|
||||
|
||||
__ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html
|
||||
|
||||
+117
-7
@@ -20,10 +20,15 @@
|
||||
import os
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
from patroni.version import __version__
|
||||
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
module_dir = os.path.abspath(os.path.join(project_root, 'patroni'))
|
||||
excludes = ['tests', 'setup.py', 'conf']
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
@@ -33,11 +38,28 @@ from patroni.version import __version__
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = ['sphinx.ext.intersphinx',
|
||||
extensions = [
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.mathjax',
|
||||
'sphinx.ext.ifconfig',
|
||||
'sphinx.ext.viewcode']
|
||||
# 'sphinx.ext.viewcode',
|
||||
'sphinx_github_style', # Generate "View on GitHub" for source code
|
||||
'sphinxcontrib.apidoc', # For generating module docs from code
|
||||
'sphinx.ext.autodoc', # For generating module docs from docstrings
|
||||
'sphinx.ext.napoleon', # For Google and Numpy formatted docstrings
|
||||
]
|
||||
apidoc_module_dir = module_dir
|
||||
apidoc_output_dir = 'modules'
|
||||
apidoc_excluded_paths = excludes
|
||||
apidoc_separate_modules = True
|
||||
|
||||
# Include autodoc for all members, including private ones and the ones that are missing a docstring.
|
||||
autodoc_default_options = {
|
||||
"members": True,
|
||||
"undoc-members": True,
|
||||
"private-members": True,
|
||||
}
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
@@ -70,7 +92,7 @@ release = __version__
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
language = 'en'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
@@ -90,10 +112,10 @@ todo_include_todos = True
|
||||
# a list of builtin themes.
|
||||
#
|
||||
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
@@ -107,6 +129,34 @@ if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Replace "source" links with "edit on GitHub" when using rtd theme
|
||||
html_context = {
|
||||
'display_github': True,
|
||||
'github_user': 'zalando',
|
||||
'github_repo': 'patroni',
|
||||
'github_version': 'master',
|
||||
'conf_py_path': '/docs/',
|
||||
}
|
||||
|
||||
# sphinx-github-style options, https://sphinx-github-style.readthedocs.io/en/latest/index.html
|
||||
|
||||
# The name of the top-level package.
|
||||
top_level = "patroni"
|
||||
|
||||
# The blob to link to on GitHub - any of "head", "last_tag", or "{blob}"
|
||||
# linkcode_blob = 'head'
|
||||
|
||||
# The link to your GitHub repository formatted as https://github.com/user/repo
|
||||
# If not provided, will attempt to create the link from the html_context dict
|
||||
# linkcode_url = f"https://github.com/{html_context['github_user']}/" \
|
||||
# f"{html_context['github_repo']}/{html_context['github_version']}"
|
||||
|
||||
# The text to use for the linkcode link
|
||||
# linkcode_link_text: str = "View on GitHub"
|
||||
|
||||
# A linkcode_resolve() function to use for resolving the link target
|
||||
# linkcode_resolve: types.FunctionType
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
@@ -165,7 +215,6 @@ texinfo_documents = [
|
||||
]
|
||||
|
||||
|
||||
|
||||
# -- Options for Epub output ----------------------------------------------
|
||||
|
||||
# Bibliographic Dublin Core info.
|
||||
@@ -187,9 +236,64 @@ epub_copyright = copyright
|
||||
epub_exclude_files = ['search.html']
|
||||
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
intersphinx_mapping = {'python': ('https://docs.python.org/', None)}
|
||||
|
||||
|
||||
# Remove these pages from index, references, toc trees, etc.
|
||||
# If the builder is not 'html' then add the API docs modules index to pages to be removed.
|
||||
exclude_from_builder = {
|
||||
'latex': ['modules/modules'],
|
||||
'epub': ['modules/modules'],
|
||||
}
|
||||
# Internal holding list, anything added here will always be excluded
|
||||
_docs_to_remove = []
|
||||
|
||||
|
||||
def builder_inited(app):
|
||||
"""Run during Sphinx `builder-inited` phase.
|
||||
|
||||
Set a config value to builder name and add module docs to `docs_to_remove`.
|
||||
"""
|
||||
print(f'The builder is: {app.builder.name}')
|
||||
app.add_config_value('builder', app.builder.name, 'env')
|
||||
|
||||
# Remove pages when builder matches any referenced in exclude_from_builder
|
||||
if exclude_from_builder.get(app.builder.name):
|
||||
_docs_to_remove.extend(exclude_from_builder[app.builder.name])
|
||||
|
||||
|
||||
def env_get_outdated(app, env, added, changed, removed):
|
||||
"""Run during Sphinx `env-get-outdated` phase.
|
||||
|
||||
Remove the items listed in `docs_to_remove` from known pages.
|
||||
"""
|
||||
added.difference_update(_docs_to_remove)
|
||||
changed.difference_update(_docs_to_remove)
|
||||
removed.update(_docs_to_remove)
|
||||
return []
|
||||
|
||||
|
||||
def doctree_read(app, doctree):
|
||||
"""Run during Sphinx `doctree-read` phase.
|
||||
|
||||
Remove the items listed in `docs_to_remove` from the table of contents.
|
||||
"""
|
||||
from sphinx import addnodes
|
||||
for toc_tree_node in doctree.traverse(addnodes.toctree):
|
||||
for e in toc_tree_node['entries']:
|
||||
ref = str(e[1])
|
||||
if ref in _docs_to_remove:
|
||||
toc_tree_node['entries'].remove(e)
|
||||
|
||||
|
||||
def autodoc_skip(app, what, name, obj, would_skip, options):
|
||||
"""Include autodoc of ``__init__`` methods, which are skipped by default."""
|
||||
if name == "__init__":
|
||||
return False
|
||||
return would_skip
|
||||
|
||||
|
||||
|
||||
# A possibility to have an own stylesheet, to add new rules or override existing ones
|
||||
# For the latter case, the CSS specificity of the rules should be higher than the default ones
|
||||
@@ -198,3 +302,9 @@ def setup(app):
|
||||
app.add_css_file('custom.css')
|
||||
else:
|
||||
app.add_stylesheet('custom.css')
|
||||
|
||||
# Run extra steps to remove module docs when running with a non-html builder
|
||||
app.connect('builder-inited', builder_inited)
|
||||
app.connect('env-get-outdated', env_get_outdated)
|
||||
app.connect('doctree-read', doctree_read)
|
||||
app.connect("autodoc-skip-member", autodoc_skip)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.. _contributing_guidelines:
|
||||
|
||||
Contributing guidelines
|
||||
=======================
|
||||
|
||||
.. _chatting:
|
||||
|
||||
Chatting
|
||||
--------
|
||||
|
||||
If you have a question, looking for an interactive troubleshooting help or want to chat with other Patroni users, join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
|
||||
|
||||
.. _reporting_bugs:
|
||||
|
||||
Reporting bugs
|
||||
--------------
|
||||
|
||||
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
|
||||
Also please double check if the issue already exists in our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
Requirements for running behave tests:
|
||||
|
||||
#. PostgreSQL packages including `contrib <https://www.postgresql.org/docs/current/contrib.html>`__ modules need to be installed.
|
||||
#. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
#. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify pip3.
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
After you have all dependencies installed, you can run the various test suites:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify python3.
|
||||
|
||||
# Run flake8 to check syntax and formatting:
|
||||
python setup.py flake8
|
||||
|
||||
# Run the pytest suite in tests/:
|
||||
python setup.py test
|
||||
|
||||
# Moreover, you may want to run tests in different scopes for debugging purposes,
|
||||
# the -s option include print output during test execution.
|
||||
# Tests in pytest typically follow the pattern: FILEPATH::CLASSNAME::TESTNAME.
|
||||
pytest -s tests/test_api.py
|
||||
pytest -s tests/test_api.py::TestRestApiHandler
|
||||
pytest -s tests/test_api.py::TestRestApiHandler::test_do_GET
|
||||
|
||||
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
|
||||
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
|
||||
DCS=raft python -m behave
|
||||
|
||||
Testing with tox
|
||||
----------------
|
||||
|
||||
To run tox tests you only need to install one dependency (other than Python)
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install tox>=4
|
||||
|
||||
If you wish to run `behave` tests then you also need docker installed.
|
||||
|
||||
Tox configuration in `tox.ini` has "environments" to run the following tasks:
|
||||
|
||||
* lint: Python code lint with `flake8`
|
||||
* test: unit tests for all available python interpreters with `pytest`,
|
||||
generates XML reports or HTML reports if a TTY is detected
|
||||
* dep: detect package dependency conflicts using `pipdeptree`
|
||||
* type: static type checking with `pyright`
|
||||
* black: code formatting with `black`
|
||||
* docker-build: build docker image used for the `behave` env
|
||||
* docker-cmd: run arbitrary command with the above image
|
||||
* docker-behave-etcd: run tox for behave tests with above image
|
||||
* py*behave: run behave with available python interpreters (without docker, although
|
||||
this is what is called inside docker containers)
|
||||
* docs: build docs with `sphinx`
|
||||
|
||||
Running tox
|
||||
^^^^^^^^^^^
|
||||
|
||||
To run the default env list; dep, lint, test, and docs, just run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox
|
||||
|
||||
The `test` envs can be run with the label `test`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m test
|
||||
|
||||
The `behave` docker tests can be run with the label `behave`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m behave
|
||||
|
||||
Similarly, docs has the label `docs`.
|
||||
|
||||
All other envs can be run with their respective env names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -e lint
|
||||
tox -e py39-test-lin
|
||||
|
||||
It is also possible to select partial env lists using `factors`. For example, if you want to run
|
||||
all envs for python 3.10:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -f py310
|
||||
|
||||
This is equivalent to running all the envs listed below:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ tox -l -f py310
|
||||
py310-test-lin
|
||||
py310-test-mac
|
||||
py310-test-win
|
||||
py310-type-lin
|
||||
py310-type-mac
|
||||
py310-type-win
|
||||
py310-behave-etcd-lin
|
||||
py310-behave-etcd-win
|
||||
py310-behave-etcd-mac
|
||||
|
||||
|
||||
You can list all configured combinations of environments with tox (>=v4) like so
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox l
|
||||
|
||||
The envs `test` and `docs` will attempt to open the HTML output files
|
||||
when the job completes, if tox is run with an active terminal. This
|
||||
is intended to be for benefit of the developer running this env locally.
|
||||
It will attempt to run `open` on a mac and `xdg-open` on Linux.
|
||||
To use a different command set the env var `OPEN_CMD` to the name or path of
|
||||
the command. If this step fails it will not fail the run overall.
|
||||
If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op command.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
OPEN_CMD=: tox -m docs
|
||||
|
||||
Behave tests
|
||||
^^^^^^^^^^^^
|
||||
|
||||
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 16 and then run all
|
||||
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
|
||||
Postgres or to a specific feature set or steps.
|
||||
|
||||
To specify the version of postgres include the full name of the dependent image build env that you want and then the
|
||||
behave env name. For instance if you want Postgres 14 use:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -e pg14-docker-build,pg14-docker-behave-etcd-lin
|
||||
|
||||
If on the other hand you want to test a specific feature you can pass positional arguments to behave. This will run
|
||||
the watchdog behave feature test scenario with all versions of Postgres.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tox -m behave -- features/watchdog.feature
|
||||
|
||||
Of course you can combine the two.
|
||||
|
||||
Contributing a pull request
|
||||
---------------------------
|
||||
|
||||
#. Fork the repository, develop and test your code changes.
|
||||
#. Reflect changes in the user documentation.
|
||||
#. Submit a pull request with a clear description of the changes objective. Link an existing issue if necessary.
|
||||
|
||||
You'll get feedback about your pull request as soon as possible.
|
||||
|
||||
Happy Patroni hacking ;-)
|
||||
@@ -23,7 +23,7 @@ In general, it is impossible to distinguish between these two from a single node
|
||||
DCS Failsafe Mode
|
||||
-----------------
|
||||
|
||||
We introduce a new special option, the ``failsafe_mode``. It could be enabled only via global configuration stored in the DCS ``/config`` key. If the failsafe mode is enabled and the leader lock update in DCS failed due to reasons different from the version/value/index mismatch, Postgres may continue to run as a primary if it can access all known members of the cluster via Patroni REST API.
|
||||
We introduce a new special option, the ``failsafe_mode``. It could be enabled only via global :ref:`dynamic configuration <dynamic_configuration>` stored in the DCS ``/config`` key. If the failsafe mode is enabled and the leader lock update in DCS failed due to reasons different from the version/value/index mismatch, Postgres may continue to run as a primary if it can access all known members of the cluster via Patroni REST API.
|
||||
|
||||
|
||||
Low-level implementation details
|
||||
@@ -53,11 +53,11 @@ F.A.Q.
|
||||
- What if all members of the Patroni cluster are lost while DCS is down?
|
||||
|
||||
Patroni could be configured to create the new replica from the backup even when the cluster doesn't have a leader. But, if the new member isn't present in the ``/failsafe`` key, it will not be able to grab the leader lock and promote.
|
||||
|
||||
|
||||
- What will happen if the primary lost access to DCS while replicas didn't?
|
||||
|
||||
The primary will execute the failsafe code and contact all known replicas. These replicas will use this information as an indicator that the primary is alive and will not start the leader race even if the leader lock in DCS has expired.
|
||||
|
||||
- How to enable the Failsafe Mode?
|
||||
|
||||
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or ``patronictl edit-config -s failsafe_mode=true``
|
||||
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or :ref:`patronictl edit-config -s failsafe_mode=true <patronictl_edit_config_parameters>`
|
||||
|
||||
@@ -1,89 +1,110 @@
|
||||
.. _dynamic_configuration:
|
||||
|
||||
Patroni configuration
|
||||
=====================
|
||||
==============================
|
||||
Dynamic Configuration Settings
|
||||
==============================
|
||||
|
||||
Patroni configuration is stored in the DCS (Distributed Configuration Store). There are 3 types of configuration:
|
||||
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
|
||||
|
||||
- Dynamic configuration.
|
||||
These options can be set in DCS at any time. If the options changed are not part of the startup configuration,
|
||||
they are applied asynchronously (upon the next wake up cycle) to every node, which gets subsequently reloaded.
|
||||
If the node requires a restart to apply the configuration (for options with context postmaster, if their values
|
||||
have changed), a special flag, ``pending_restart`` indicating this, is set in the members.data JSON.
|
||||
Additionally, the node status also indicates this, by showing ``"restart_pending": true``.
|
||||
In order to change the dynamic configuration you can use either :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
|
||||
- Local :ref:`configuration <settings>` (patroni.yml).
|
||||
These options are defined in the configuration file and take precedence over dynamic configuration.
|
||||
patroni.yml could be changed and reloaded in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``.
|
||||
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10, minimum possible value: 1
|
||||
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30, minimum possible value: 20
|
||||
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10, minimum possible value: 3
|
||||
|
||||
- Environment :ref:`configuration <environment>`.
|
||||
It is possible to set/override some of the "Local" configuration parameters with environment variables.
|
||||
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
|
||||
.. warning::
|
||||
when changing values of **loop_wait**, **retry_timeout**, or **ttl** you have to follow the rule:
|
||||
|
||||
The local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
|
||||
.. code-block:: python
|
||||
|
||||
Some of the PostgreSQL parameters must hold the same values on the primary and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
|
||||
|
||||
- max_connections: 100
|
||||
- max_locks_per_transaction: 64
|
||||
- max_worker_processes: 8
|
||||
- max_prepared_transactions: 0
|
||||
- wal_level: hot_standby
|
||||
- wal_log_hints: on
|
||||
- track_commit_timestamp: off
|
||||
|
||||
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
|
||||
|
||||
- max_wal_senders: 5
|
||||
- max_replication_slots: 5
|
||||
- wal_keep_segments: 8
|
||||
- wal_keep_size: 128MB
|
||||
|
||||
These parameters are validated to ensure they are sane, or meet a minimum value.
|
||||
|
||||
There are some other Postgres parameters controlled by Patroni:
|
||||
|
||||
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- cluster_name - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
|
||||
- hot_standby: on
|
||||
|
||||
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
|
||||
loop_wait + 2 * retry_timeout <= ttl
|
||||
|
||||
|
||||
When applying the local or dynamic configuration options, the following actions are taken:
|
||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
|
||||
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
||||
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
|
||||
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
|
||||
- **postgresql**:
|
||||
|
||||
- The node first checks if there is a postgresql.base.conf or if the ``custom_conf`` parameter is set.
|
||||
- If the `custom_conf` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
|
||||
- If the `custom_conf` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
|
||||
- If there is no `custom_conf` nor `postgresql.base.conf`, the original postgresql.conf is taken and renamed to postgresql.base.conf.
|
||||
- The dynamic options (with the exceptions above) are dumped into the postgresql.conf and an include is set in
|
||||
postgresql.conf to the used base configuration (either postgresql.base.conf or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
|
||||
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
|
||||
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
|
||||
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
|
||||
- **use\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`.
|
||||
- **use\_slots**: whether or not to use replication slots. Defaults to `true` on PostgreSQL 9.4+.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. There is no recovery.conf anymore in PostgreSQL 12, but you may continue using this section, because Patroni handles it transparently.
|
||||
- **parameters**: list of configuration settings for Postgres.
|
||||
|
||||
The parameters would be applied in the following order (run-time are given the highest priority):
|
||||
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. Patroni ignores this parameter if ``hba_file`` PostgreSQL parameter is set to a non-default value.
|
||||
|
||||
1. load parameters from file `postgresql.base.conf` (or from a `custom_conf` file, if set)
|
||||
2. load parameters from file `postgresql.conf`
|
||||
3. load parameters from file `postgresql.auto.conf`
|
||||
4. run-time parameter using `-o --name=value`
|
||||
- **- host all all 0.0.0.0/0 md5**
|
||||
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
||||
|
||||
This allows configuration for all the nodes (2), configuration for a specific node using `ALTER SYSTEM` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
|
||||
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Patroni ignores this parameter if ``ident_file`` PostgreSQL parameter is set to a non-default value.
|
||||
|
||||
- **- mapname1 systemname1 pguser1**
|
||||
- **- mapname1 systemname2 pguser2**
|
||||
|
||||
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
|
||||
|
||||
- **host**: an address of remote node
|
||||
- **port**: a port of remote node
|
||||
- **primary\_slot\_name**: which slot on the remote node to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
|
||||
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote primary, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **restore\_command**: command to restore WAL records from the remote primary to nodes in a standby cluster, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **archive\_cleanup\_command**: cleanup command for standby leader
|
||||
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
|
||||
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. With PostgreSQL 11 onwards permanent physical slots are created on all nodes and their position is advanced every **loop_wait** seconds. For PostgreSQL versions older than 11 permanent physical replication slots are maintained only on the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent replication slots requires **postgresql.use_slots** to be set to ``true``. If there are permanent logical replication slots defined Patroni will automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
|
||||
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current node it will not be created on this node. If you add a permanent physical replication slot which name matches the name of a Patroni member, Patroni will ensure that the slot that was created is not removed even if the corresponding member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when you want replication slots used by members to persist during temporary failures or when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS, when the slot is no longer required, due to its effect on normal functioning of Patroni.
|
||||
|
||||
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
|
||||
- **database**: the database name where logical slots should be created.
|
||||
- **plugin**: the plugin name for the logical slot.
|
||||
|
||||
- **ignore\_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
|
||||
|
||||
- **name**: the name of the replication slot.
|
||||
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
|
||||
- **database**: the database name (when matching a ``logical`` slot).
|
||||
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
|
||||
|
||||
Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
slots:
|
||||
permanent_logical_slot_name:
|
||||
type: logical
|
||||
database: my_db
|
||||
plugin: test_decoding
|
||||
permanent_physical_slot_name:
|
||||
type: physical
|
||||
...
|
||||
ignore_slots:
|
||||
- name: ignored_logical_slot_name
|
||||
type: logical
|
||||
database: my_db
|
||||
plugin: test_decoding
|
||||
- name: ignored_physical_slot_name
|
||||
type: physical
|
||||
...
|
||||
|
||||
Note: if cluster topology is static (fixed number of nodes that never change their names) you can configure permanent physical replication slots with names corresponding to names of nodes to avoid recycling of WAL files while replica is temporary down:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
slots:
|
||||
node_name1:
|
||||
type: physical
|
||||
node_name2:
|
||||
type: physical
|
||||
node_name3:
|
||||
type: physical
|
||||
...
|
||||
|
||||
|
||||
Also, the following Patroni configuration options can be changed only dynamically:
|
||||
|
||||
- ttl: 30
|
||||
- loop_wait: 10
|
||||
- retry_timeouts: 10
|
||||
- maximum_lag_on_failover: 1048576
|
||||
- max_timelines_history: 0
|
||||
- check_timeline: false
|
||||
- postgresql.use_slots: true
|
||||
|
||||
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its
|
||||
run-time values.
|
||||
|
||||
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
|
||||
.. warning::
|
||||
Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster.
|
||||
An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them.
|
||||
|
||||
+56
-16
@@ -10,18 +10,58 @@ To deploy a Patroni cluster without using a pre-existing PostgreSQL instance, se
|
||||
Procedure
|
||||
---------
|
||||
|
||||
A Patroni cluster can be started with a data directory from a single-node PostgreSQL database. This is achieved by following closely these steps:
|
||||
You can find below an overview of steps for converting an existing Postgres cluster to a Patroni managed cluster. In the steps we assume all nodes that are part of the existing cluster are currently up and running, and that you *do not* intend to change Postgres configuration while the migration is ongoing. The steps:
|
||||
|
||||
1. Manually start PostgreSQL daemon
|
||||
2. Create Patroni superuser and replication users as defined in the :ref:`authentication <postgresql_settings>` section of the Patroni configuration. If this user is created in SQL, the following queries achieve this:
|
||||
#. Create the Postgres users as explained for :ref:`authentication <postgresql_settings>` section of the Patroni configuration. You can find sample SQL commands to create the users in the code block below, in which you need to replace the usernames and passwords as per your environment. If you already have the relevant users, then you can skip this step.
|
||||
|
||||
.. code-block:: sql
|
||||
.. code-block:: sql
|
||||
|
||||
CREATE USER $PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD '$PATRONI_SUPERUSER_PASSWORD';
|
||||
CREATE USER $PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD '$PATRONI_REPLICATION_PASSWORD';
|
||||
-- Patroni superuser
|
||||
-- Replace PATRONI_SUPERUSER_USERNAME and PATRONI_SUPERUSER_PASSWORD accordingly
|
||||
CREATE USER PATRONI_SUPERUSER_USERNAME WITH SUPERUSER ENCRYPTED PASSWORD 'PATRONI_SUPERUSER_PASSWORD';
|
||||
|
||||
3. Start Patroni (e.g. ``patroni /etc/patroni/patroni.yml``). It automatically detects that PostgreSQL daemon is already running but its configuration might be out-of-date.
|
||||
4. Ask Patroni to restart the node with ``patronictl restart cluster-name node-name``. This step is only required if PostgreSQL configuration is out-of-date.
|
||||
-- Patroni replication user
|
||||
-- Replace PATRONI_REPLICATION_USERNAME and PATRONI_REPLICATION_PASSWORD accordingly
|
||||
CREATE USER PATRONI_REPLICATION_USERNAME WITH REPLICATION ENCRYPTED PASSWORD 'PATRONI_REPLICATION_PASSWORD';
|
||||
|
||||
-- Patroni rewind user, if you intend to enable use_pg_rewind in your Patroni configuration
|
||||
-- Replace PATRONI_REWIND_USERNAME and PATRONI_REWIND_PASSWORD accordingly
|
||||
CREATE USER PATRONI_REWIND_USERNAME WITH ENCRYPTED PASSWORD 'PATRONI_REWIND_PASSWORD';
|
||||
GRANT EXECUTE ON function pg_catalog.pg_ls_dir(text, boolean, boolean) TO PATRONI_REWIND_USERNAME;
|
||||
GRANT EXECUTE ON function pg_catalog.pg_stat_file(text, boolean) TO PATRONI_REWIND_USERNAME;
|
||||
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text) TO PATRONI_REWIND_USERNAME;
|
||||
GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text, bigint, bigint, boolean) TO PATRONI_REWIND_USERNAME;
|
||||
|
||||
#. Perform the following steps on all Postgres nodes. Perform all steps on one node before proceeding with the next node. Start with the primary node, then proceed with each standby node:
|
||||
|
||||
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
|
||||
|
||||
#. Create a YAML configuration file for Patroni. You can use :ref:`Patroni configuration generation and validation tooling <validate_generate_config>` for that.
|
||||
|
||||
* **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings <yaml_configuration>` for details.
|
||||
|
||||
#. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance.
|
||||
|
||||
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through :ref:`patronictl restart cluster-name member-name <patronictl_restart_parameters>` command. For minimal downtime you might want to split this step into:
|
||||
|
||||
#. Immediate restart of the standby nodes.
|
||||
#. Scheduled restart of the primary node within a maintenance window.
|
||||
|
||||
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through :ref:`patronictl edit-config cluster-name member-name <patronictl_edit_config_parameters>` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
-- Assume original_slot_for_member_x is the name of the slot in your original
|
||||
-- cluster for replicating changes to member X, and slot_for_member_x is the
|
||||
-- slot created by Patroni for that purpose. You need restart_lsn of
|
||||
-- slot_for_member_x to be >= restart_lsn of original_slot_for_member_x
|
||||
SELECT slot_name,
|
||||
restart_lsn
|
||||
FROM pg_replication_slots
|
||||
WHERE slot_name IN (
|
||||
'original_slot_for_member_x',
|
||||
'slot_for_member_x'
|
||||
)
|
||||
|
||||
.. _major_upgrade:
|
||||
|
||||
@@ -30,14 +70,14 @@ Major Upgrade of PostgreSQL Version
|
||||
|
||||
The only possible way to do a major upgrade currently is:
|
||||
|
||||
1. Stop Patroni
|
||||
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
|
||||
3. Update patroni.yml
|
||||
4. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
|
||||
5. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
|
||||
6. Start Patroni on the primary node.
|
||||
7. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
|
||||
8. Start Patroni on the standby nodes and wait for the replication to complete.
|
||||
#. Stop Patroni
|
||||
#. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
|
||||
#. Update patroni.yml
|
||||
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running :ref:`patronictl remove cluster-name <patronictl_remove_parameters>` . It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
|
||||
#. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
|
||||
#. Start Patroni on the primary node.
|
||||
#. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
|
||||
#. Start Patroni on the standby nodes and wait for the replication to complete.
|
||||
|
||||
Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you.
|
||||
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
.. _faq:
|
||||
|
||||
FAQ
|
||||
===
|
||||
|
||||
In this section you will find answers for the most frequently asked questions about Patroni.
|
||||
Each sub-section attempts to focus on different kinds of questions.
|
||||
|
||||
We hope that this helps you to clarify most of your questions.
|
||||
If you still have further concerns or find yourself facing an unexpected issue, please refer to :ref:`chatting` and :ref:`reporting_bugs` for instructions on how to get help or report issues.
|
||||
|
||||
Comparison with other HA solutions
|
||||
----------------------------------
|
||||
|
||||
Why does Patroni require a separate cluster of DCS nodes while other solutions like ``repmgr`` do not?
|
||||
There are different ways of implementing HA solutions, each of them with their pros and cons.
|
||||
|
||||
Software like ``repmgr`` performs communication among the nodes to decide when actions should be taken.
|
||||
|
||||
Patroni on the other hand relies on the state stored in the DCS. The DCS acts as a source of truth for Patroni to decide what it should do.
|
||||
|
||||
While having a separate DCS cluster can make you bloat your architecture, this approach also makes it less likely for split-brain scenarios to happen in your Postgres cluster.
|
||||
|
||||
What is the difference between Patroni and other HA solutions in regards to Postgres management?
|
||||
Patroni does not just manage the high availability of the Postgres cluster but also manages Postgres itself.
|
||||
|
||||
If Postgres nodes do not exist yet, it takes care of bootstrapping the primary and the standby nodes, and also manages Postgres configuration of the nodes. If the Postgres nodes already exist, Patroni will take over management of the cluster.
|
||||
|
||||
Besides the above, Patroni also has self-healing capabilities. In other words, if a primary node fails, Patroni will not only fail over to a replica, but also attempt to rejoin the former primary as a replica of the new primary. Similarly, if a replica fails, Patroni will attempt to rejoin that replica.
|
||||
|
||||
That is way we call Patroni as a "template for HA solutions". It goes further than just managing physical replication: it manages Postgres as a whole.
|
||||
|
||||
DCS
|
||||
---
|
||||
|
||||
Can I use the same ``etcd`` cluster to store data from two or more Patroni clusters?
|
||||
Yes, you can!
|
||||
|
||||
Information about a Patroni cluster is stored in the DCS under a path prefixed with the ``namespace`` and ``scope`` Patroni settings.
|
||||
|
||||
As long as you do not have conflicting namespace and scope across different Patroni clusters, you should be able to use the same DCS cluster to store information from multiple Patroni clusters.
|
||||
|
||||
What occurs if I attempt to use the same combination of ``namespace`` and ``scope`` for different Patroni clusters that point to the same DCS cluster?
|
||||
The second Patroni cluster that attempts to use the same ``namespace`` and ``scope`` will not be able to manage Postgres because it will find information related with that same combination in the DCS, but with an incompatible Postgres system identifier.
|
||||
The mismatch on the system identifier causes Patroni to abort the management of the second cluster, as it assumes that refers to a different cluster and that the user has misconfigured Patroni.
|
||||
|
||||
Make sure to use different ``namespace`` / ``scope`` when dealing with different Patroni clusters that share the same DCS cluster.
|
||||
|
||||
What occurs if I lose my DCS cluster?
|
||||
The DCS is used to store basically status and the dynamic configuration of the Patroni cluster.
|
||||
|
||||
They very first consequence is that all the Patroni clusters that rely on that DCS will go to read-only mode -- unless :ref:`dcs_failsafe_mode` is enabled.
|
||||
|
||||
What should I do if I lose my DCS cluster?
|
||||
There are three possible outcomes upon losing your DCS cluster:
|
||||
|
||||
1. The DCS cluster is fully recovered: this requires no action from the Patroni side. Once the DCS cluster is recovered, Patroni should be able to recover too;
|
||||
2. The DCS cluster is re-created in place, and the endpoints remain the same. No changes are required on the Patroni side;
|
||||
3. A new DCS cluster is created with different endpoints. You will need to update the DCS endpoints in the Patroni configuration of each Patroni node.
|
||||
|
||||
If you face scenario ``2.`` or ``3.`` Patroni will take care of creating the status information again based on the current status of the cluster, and recreate the dynamic configuration on the DCS based on a backup file named ``patroni.dynamic.json`` which is stored inside the Postgres data directory of each member of the Patroni cluster.
|
||||
|
||||
What occurs if I lose majority in my DCS cluster?
|
||||
The DCS will become unresponsive, which will cause Patroni to demote the current read/write Postgres node.
|
||||
|
||||
Remember: Patroni relies on the state of the DCS to take actions on the cluster.
|
||||
|
||||
You can use the :ref:`dcs_failsafe_mode` to alleviate that situation.
|
||||
|
||||
patronictl
|
||||
----------
|
||||
|
||||
Do I need to run :ref:`patronictl` in the Patroni host?
|
||||
No, you do not need to do that.
|
||||
|
||||
Running :ref:`patronictl` in the Patroni host is handy if you have access to the Patroni host because you can use the very same configuration file from the ``patroni`` agent for the :ref:`patronictl` application.
|
||||
|
||||
However, :ref:`patronictl` is basically a client and it can be executed from remote machines. You just need to provide it with enough configuration so it can reach the DCS and the REST API of the Patroni member(s).
|
||||
|
||||
Why did the information from one of my Patroni members disappear from the output of :ref:`patronictl_list` command?
|
||||
Information shown by :ref:`patronictl_list` is based on the contents of the DCS.
|
||||
|
||||
If information about a member disappeared from the DCS it is very likely that the Patroni agent on that node is not running anymore, or it is not able to communicate with the DCS.
|
||||
|
||||
As the member is not able to update the information, the information eventually expires from the DCS, and consequently the member is not shown anymore in the output of :ref:`patronictl_list`.
|
||||
|
||||
Why is the information about one of my Patroni members not up-to-date in the output of :ref:`patronictl_list` command?
|
||||
Information shown by :ref:`patronictl_list` is based on the contents of the DCS.
|
||||
|
||||
By default, that information is updated by Patroni roughly every ``loop_wait`` seconds.
|
||||
In other words, even if everything is normally functional you may still see a "delay" of up to ``loop_wait`` seconds in the information stored in the DCS.
|
||||
|
||||
Be aware that that is not a rule, though. Some operations performed by Patroni cause it to immediately update the DCS information.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
What is the difference between dynamic configuration and local configuration?
|
||||
Dynamic configuration (or global configuration) is the configuration stored in the DCS, and which is applied to all members of the Patroni cluster.
|
||||
This is primarily where you should store your configuration.
|
||||
|
||||
Settings that are specific to a node, or settings that you would like to overwrite the global configuration with, you should set only on the desired Patroni member as a local configuration.
|
||||
That local configuration can be specified either through the configuration file or through environment variables.
|
||||
|
||||
See more in :ref:`patroni_configuration`.
|
||||
|
||||
What are the types of configuration in Patroni, and what is the precedence?
|
||||
The types are:
|
||||
|
||||
* Dynamic configuration: applied to all members;
|
||||
* Local configuration: applied to the local member, overrides dynamic configuration;
|
||||
* Environment configuration: applied to the local member, overrides both dynamic and local configuration.
|
||||
|
||||
**Note:** some Postgres GUCs can only be set globally, i.e., through dynamic configuration. Besides that, there are GUCs which Patroni enforces a hard-coded value.
|
||||
|
||||
See more in :ref:`patroni_configuration`.
|
||||
|
||||
Is there any facility to help me create my Patroni configuration file?
|
||||
Yes, there is.
|
||||
|
||||
You can use ``patroni --generate-sample-config`` or ``patroni --generate-config`` commands to generate a sample Patroni configuration or a Patroni configuration based on an existing Postgres instance, respectively.
|
||||
|
||||
Please refer to :ref:`generate_sample_config` and :ref:`generate_config` for more details.
|
||||
|
||||
I changed my parameters under ``bootstrap.dcs`` configuration but Patroni is not applying the changes to the cluster members. What is wrong?
|
||||
The values configured under ``bootstrap.dcs`` are only used when bootstrapping a fresh cluster. Those values will be written to the DCS during the bootstrap.
|
||||
|
||||
After the bootstrap phase finishes, you will only be able to change the dynamic configuration through the DCS.
|
||||
|
||||
Refer to the next question for more details.
|
||||
|
||||
How can I change my dynamic configuration?
|
||||
You need to change the configuration in the DCS. That is accomplished either through:
|
||||
|
||||
* :ref:`patronictl_edit_config`; or
|
||||
* A ``PATCH`` request to :ref:`config_endpoint`.
|
||||
|
||||
How can I change my local configuration?
|
||||
You need to change the configuration file of the corresponding Patroni member and signal the Patroni agent with ``SIHGUP``. You can do that using either of these approaches:
|
||||
|
||||
* Send a ``POST`` request to the REST API :ref:`reload_endpoint`; or
|
||||
* Run :ref:`patronictl_reload`; or
|
||||
* Locally signal the Patroni process with ``SIGHUP``:
|
||||
|
||||
* If you started Patroni through systemd, you can use the command ``systemctl reload PATRONI_UNIT.service``, ``PATRONI_UNIT`` being the name of the Patroni service; or
|
||||
* If you started Patroni through other means, you will need to identify the ``patroni`` process and run ``kill -s HUP PID``, ``PID`` being the process ID of the ``patroni`` process.
|
||||
|
||||
**Note:** there are cases where a reload through the :ref:`patronictl_reload` may not work:
|
||||
|
||||
* Expired REST API certificates: you can mitigate that by using the ``-k`` option of the :ref:`patronictl`;
|
||||
* Wrong credentials: for example when changing ``restapi`` or ``ctl`` credentials in the configuration file, and using that same configuration file for Patroni and :ref:`patronictl`.
|
||||
|
||||
How can I change my environment configuration?
|
||||
The environment configuration is only read by Patroni during startup.
|
||||
|
||||
With that in mind, if you change the environment configuration you will need to restart the corresponding Patroni agent.
|
||||
|
||||
Take care to not cause a failover in the cluster! You might be interested in checking :ref:`patronictl_pause`.
|
||||
|
||||
What occurs if I change a Postgres GUC that requires a reload?
|
||||
When you change the dynamic or the local configuration as explained in the previous questions, Patroni will take care of reloading the Postgres configuration for you.
|
||||
|
||||
What occurs if I change a Postgres GUC that requires a restart?
|
||||
Patroni will mark the affected members with a flag of ``pending restart``.
|
||||
|
||||
It is up to you to determine when and how to restart the members. That can be accomplished either through:
|
||||
|
||||
* :ref:`patronictl_restart`; or
|
||||
* A ``POST`` request to :ref:`restart_endpoint`.
|
||||
|
||||
**Note:** some Postgres GUCs require a special management in terms of the order for restarting the Postgres nodes. Refer to :ref:`shared_memory_gucs` for more details.
|
||||
|
||||
What is the difference between ``etcd`` and ``etcd3`` in Patroni configuration?
|
||||
``etcd`` uses the API version 2 of ``etcd``, while ``etcd3`` uses the API version 3 of ``etcd``.
|
||||
|
||||
Be aware that information stored by the API version 2 is not manageable by API version 3 and vice-versa.
|
||||
|
||||
We recommend that you configure ``etcd3`` instead of ``etcd`` because:
|
||||
|
||||
* API version 2 is disabled by default from Etcd v3.4 onward;
|
||||
* API version 2 will be completely removed on Etcd v3.6.
|
||||
|
||||
I have ``use_slots`` enabled in my Patroni configuration, but when a cluster member goes offline for some time, the replication slot used by that member is dropped on the upstream node. What can I do to avoid that issue?
|
||||
You can configure a permanent physical replication slot for the members.
|
||||
|
||||
Since Patroni ``3.2.0`` it is now possible to have member slots as permanent slots managed by Patroni.
|
||||
|
||||
Patroni will create the permanent physical slots on all nodes, and make sure to not remove the slots, as well as to advance the slots' LSN on all nodes according to the LSN that has been consumed by the member.
|
||||
|
||||
Later, if you decide to remove the corresponding member, it's **your responsability** to adjust the permanent slots configuration, otherwise Patroni will keep the slots around forever.
|
||||
|
||||
**Note:** on Patroni older than ``3.2.0`` you could still have member slots configured as permanent physical slots, however they would be managed only on the current leader. That is, in case of failover/switchover these slots would be created on the new leader, but that wouldn't guarantee that it had all WAL segments for the absent node.
|
||||
|
||||
**Note:** even with Patroni ``3.2.0`` there might be a small race condition. In the very beginning, when the slot is created on the replica it could be ahead of the same slot on the leader and in case if nobody is consuming the slot there is still a chance that some files could be missing after failover. With that in mind, it is recommended that you configure continuous archiving, which makes it possible to restore required WALs or perform PITR.
|
||||
|
||||
What is the difference between ``loop_wait``, ``retry_timeout`` and ``ttl``?
|
||||
Patroni performs what we call a HA cycle from time to time. On each HA cycle it takes care of performing a series of checks on the cluster to determine its healthiness, and depending on the status it may take actions, like failing over to a standby.
|
||||
|
||||
``loop_wait`` determines for how long, in seconds, Patroni should sleep before performing a new cycle of HA checks.
|
||||
|
||||
``retry_timeout`` sets the timeout for retry operations on the DCS and on Postgres. For example: if the DCS is unresponsive for more than ``retry_timeout`` seconds, Patroni might demote the primary node as a security action.
|
||||
|
||||
``ttl`` sets the lease time on the ``leader`` lock in the DCS. If the current leader of the cluster is not able to renew the lease during its HA cycles for longer than ``ttl``, then the lease will expire and that will trigger a ``leader race`` in the cluster.
|
||||
|
||||
**Note:** when modifying these settings, please keep in mind that Patroni enforces the rule and minimal values described in :ref:`dynamic_configuration` section of the docs.
|
||||
|
||||
Postgres management
|
||||
-------------------
|
||||
|
||||
Can I change Postgres GUCs directly in Postgres configuration?
|
||||
You can, but you should avoid that.
|
||||
|
||||
Postgres configuration is managed by Patroni, and attempts to edit the configuration files may end up being frustrated by Patroni as it may eventually overwrite them.
|
||||
|
||||
There are a few options available to overcome the management performed by Patroni:
|
||||
|
||||
* Change Postgres GUCs through ``$PGDATA/postgresql.base.conf``; or
|
||||
* Define a ``postgresql.custom_conf`` which will be used instead of ``postgresql.base.conf`` so you can manage that externally; or
|
||||
* Change GUCs using ``ALTER SYSTEM`` / ``ALTER DATABASE`` / ``ALTER USER``.
|
||||
|
||||
You can find more information about that in the section :ref:`important_configuration_rules`.
|
||||
|
||||
In any case we recommend that you manage all the Postgres configuration through Patroni. That will centralize the management and make it easier to debug Patroni when needed.
|
||||
|
||||
Can I restart Postgres nodes directly?
|
||||
No, you should **not** attempt to manage Postgres directly!
|
||||
|
||||
Any attempt of bouncing the Postgres server without Patroni can lead your cluster to face failovers.
|
||||
|
||||
If you need to manage the Postgres server, do that through the ways exposed by Patroni.
|
||||
|
||||
Is Patroni able to take over management of an already existing Postgres cluster?
|
||||
Yes, it can!
|
||||
|
||||
Please refer to :ref:`existing_data` for detailed instructions.
|
||||
|
||||
How does Patroni manage Postgres?
|
||||
Patroni takes care of bringing Postgres up and down by running the Postgres binaries, like ``pg_ctl`` and ``postgres``.
|
||||
|
||||
With that in mind you **MUST** disable any other sources that could manage the Postgres clusters, like the systemd units, e.g. ``postgresql.service``. Only Patroni should be able to start, stop and promote Postgres instances in the cluster. Not doing so may result in split-brain scenarios. For example: if the node running as a primary failed and the unit ``postgresql.service`` is enabled, it may bring Postgres back up and cause a split-brain.
|
||||
|
||||
Concepts and requirements
|
||||
-------------------------
|
||||
|
||||
Which are the applications that make part of Patroni?
|
||||
Patroni basically ships a couple applications:
|
||||
|
||||
* ``patroni``: This is the Patroni agent, which takes care of managing a Postgres node;
|
||||
* ``patronictl``: This is a command-line utility used to interact with a Patroni cluster (perform switchovers, restarts, changes in the configuration, etc.). Please find more information in :ref:`patronictl`.
|
||||
|
||||
What is a ``standby cluster`` in Patroni?
|
||||
It is a cluster that does not have any primary Postgres node running, i.e., there is no read/write member in the cluster.
|
||||
|
||||
These kinds of clusters exist to replicate data from another cluster and are usually useful when you want to replicate data across data centers.
|
||||
|
||||
There will be a leader in the cluster which will be a standby in charge of replicating changes from a remote Postgres node.
|
||||
Then, there will be a set of standbys configured with cascading replication from such leader member.
|
||||
|
||||
**Note:** the standby cluster doesn't know anything about the source cluster which it is replicating from -- it can even use ``restore_command`` instead of WAL streaming, and may use an absolutely independent DCS cluster.
|
||||
|
||||
Refer to :ref:`standby_cluster` for more details.
|
||||
|
||||
What is a ``leader`` in Patroni?
|
||||
A ``leader`` in Patroni is like a coordinator of the cluster.
|
||||
|
||||
In a regular Patroni cluster, the ``leader`` will be the read/write node.
|
||||
|
||||
In a standby Patroni cluster, the ``leader`` (AKA ``standby leader``) will be in charge of replicating from a remote Postgres node, and cascading those changes to the other members of the standby cluster.
|
||||
|
||||
Does Patroni require a minimum number of Postgres nodes in the cluster?
|
||||
No, you can run Patroni with any number of Postgres nodes.
|
||||
|
||||
Remember: Patroni is decoupled from the DCS.
|
||||
|
||||
What does ``pause`` mean in Patroni?
|
||||
Pause is an operation exposed by Patroni so the user can ask Patroni to step back in regards to Postgres management.
|
||||
|
||||
That is mainly useful when you want to perform maintenance on the cluster, and would like to avoid that Patroni takes decisions related with HA, like failing over to a standby when you stop the primary.
|
||||
|
||||
You can find more information about that in :ref:`pause`.
|
||||
|
||||
Automatic failover
|
||||
------------------
|
||||
|
||||
How does the automatic failover mechanism of Patroni work?
|
||||
Patroni automatic failover is based on what we call ``leader race``.
|
||||
|
||||
Patroni stores the cluster's status in the DCS, among them a ``leader`` lock which holds the name of the Patroni member which is the current ``leader`` of the cluster.
|
||||
|
||||
That ``leader`` lock has a time-to-live associated with it. If the leader node fails to update the lease of the ``leader`` lock in time, the key will eventually expire from the DCS.
|
||||
|
||||
When the ``leader`` lock expires, it triggers what Patroni calls a ``leader race``: all nodes start performing checks to determine if they are the best candidates for taking over the ``leader`` role.
|
||||
Some of these checks include calls to the REST API of all other Patroni members.
|
||||
|
||||
All Patroni members that find themselves as the best candidate for taking over the ``leader`` lock will attempt to do so.
|
||||
The first Patroni member that is able to take the ``leader`` lock will promote itself to a read/write node (or ``standby leader``), and the others will be configured to follow it.
|
||||
|
||||
Can I temporarily disable automatic failover in the Patroni cluster?
|
||||
Yes, you can!
|
||||
|
||||
You can achieve that by temporarily pausing the cluster.
|
||||
This is typically useful for performing maintenance.
|
||||
|
||||
When you want to resume the automatic failover of the cluster, you just need to unpause it.
|
||||
|
||||
You can find more information about that in :ref:`pause`.
|
||||
|
||||
Bootstrapping and standbys creation
|
||||
-----------------------------------
|
||||
|
||||
How does Patroni create a primary Postgres node? What about a standby Postgres node?
|
||||
By default Patroni will use ``initdb`` to bootstrap a fresh cluster, and ``pg_basebackup`` to create standby nodes from a copy of the ``leader`` member.
|
||||
|
||||
You can customize that behavior by writing your custom bootstrap methods, and your custom replica creation methods.
|
||||
|
||||
Custom methods are usually useful when you want to restore backups created by backup tools like pgBackRest or Barman, for example.
|
||||
|
||||
For detailed information please refer to :ref:`custom_bootstrap` and :ref:`custom_replica_creation`.
|
||||
|
||||
Monitoring
|
||||
----------
|
||||
|
||||
How can I monitor my Patroni cluster?
|
||||
Patroni exposes a couple handy endpoints in its :ref:`rest_api`:
|
||||
|
||||
* ``/metrics``: exposes monitoring metrics in a format that can be consumed by Prometheus;
|
||||
* ``/patroni``: exposes the status of the cluster in a JSON format. The information shown here is very similar to what is shown by the ``/metrics`` endpoint.
|
||||
|
||||
You can use those endpoints to implement monitoring checks.
|
||||
@@ -0,0 +1,54 @@
|
||||
.. _ha_multi_dc:
|
||||
|
||||
===================
|
||||
HA multi datacenter
|
||||
===================
|
||||
|
||||
The high availability of a PostgreSQL cluster deployed in multiple data centers is based on replication, which can be synchronous or asynchronous (`replication_modes <replication_modes.rst>`_).
|
||||
|
||||
In both cases, it is important to be clear about the following concepts:
|
||||
|
||||
- Postgres can run as primary or standby leader only when it owns the leading key and can update the leading key.
|
||||
- You should run the odd number of etcd, ZooKeeper or Consul nodes: 3 or 5!
|
||||
|
||||
Synchronous Replication
|
||||
-----------------------
|
||||
|
||||
To have a multi DC cluster that can automatically tolerate a zone drop, a minimum of 3 is required.
|
||||
|
||||
The architecture diagram would be the following:
|
||||
|
||||
.. image:: _static/multi-dc-synchronous-replication.png
|
||||
|
||||
We must deploy a cluster of etcd, ZooKeeper or Consul through the different DC, with a minimum of 3 nodes, one in each zone.
|
||||
|
||||
Regarding postgres, we must deploy at least 2 nodes, in different DC. Then you have to set ``synchronous_mode: true`` in the global :ref:`dynamic configuration <dynamic_configuration>`.
|
||||
|
||||
This enables sync replication and the primary node will choose one of the nodes as synchronous.
|
||||
|
||||
Asynchronous Replication
|
||||
------------------------
|
||||
|
||||
With only two data centers it would be better to have two independent etcd clusters and run Patroni :ref:`standby cluster <standby_cluster>` in the second data center. If the first site is down, you can MANUALLY promote the ``standby_cluster``.
|
||||
|
||||
The architecture diagram would be the following:
|
||||
|
||||
.. image:: _static/multi-dc-asynchronous-replication.png
|
||||
|
||||
Automatic promotion is not possible, because DC2 will never able to figure out the state of DC1.
|
||||
|
||||
You should not use ``pg_ctl promote`` in this scenario, you need "manually promote" the healthy cluster by removing ``standby_cluster`` section from the :ref:`dynamic configuration <dynamic_configuration>`.
|
||||
|
||||
.. warning::
|
||||
If the source cluster is still up and running and you promote the standby cluster you create a split-brain.
|
||||
|
||||
In case you want to return to the "initial" state, there are only two ways of resolving it:
|
||||
|
||||
- Add the standby_cluster section back and it will trigger pg_rewind, but there are chances that pg_rewind will fail.
|
||||
- Rebuild the standby cluster from scratch.
|
||||
|
||||
Before promoting standby cluster one have to manually ensure that the source cluster is down (STONITH). When DC1 recovers, the cluster has to be converted to a standby cluster.
|
||||
|
||||
Before doing that you may manually examine the database and extract all changes that happened between the time when network between DC1 and DC2 has stopped working and the time when you manually stopped the cluster in DC1.
|
||||
|
||||
Once extracted, you may also manually apply these changes to the cluster in DC2.
|
||||
+24
-15
@@ -6,13 +6,13 @@
|
||||
Introduction
|
||||
============
|
||||
|
||||
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful.
|
||||
Patroni is a template for high availability (HA) PostgreSQL solutions using Python. For maximum accessibility, Patroni supports a variety of distributed configuration stores like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__, `Consul <https://github.com/hashicorp/consul>`__ or `Kubernetes <https://kubernetes.io>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in datacenters — or anywhere else — will hopefully find it useful.
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
|
||||
|
||||
Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with `Citus <https://www.citusdata.com>`__. Please check :ref:`Citus support <citus>` page for more information.
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the :ref:`Citus support page <citus>` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
|
||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation.
|
||||
|
||||
@@ -22,25 +22,34 @@ Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
:caption: Contents:
|
||||
|
||||
README
|
||||
citus
|
||||
dynamic_configuration
|
||||
dcs_failsafe_mode
|
||||
installation
|
||||
patroni_configuration
|
||||
rest_api
|
||||
existing_data
|
||||
ENVIRONMENT
|
||||
SETTINGS
|
||||
security
|
||||
patronictl
|
||||
replica_bootstrap
|
||||
replication_modes
|
||||
pause
|
||||
kubernetes
|
||||
watchdog
|
||||
pause
|
||||
dcs_failsafe_mode
|
||||
kubernetes
|
||||
citus
|
||||
existing_data
|
||||
security
|
||||
ha_multi_dc
|
||||
faq
|
||||
releases
|
||||
CONTRIBUTING
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
.. ifconfig:: builder == 'html'
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
.. ifconfig:: builder != 'html'
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`search`
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
.. _installation:
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Pre-requirements for Mac OS
|
||||
---------------------------
|
||||
|
||||
To install requirements on a Mac, run the following:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
brew install postgresql etcd haproxy libyaml python
|
||||
|
||||
.. _psycopg2_install_options:
|
||||
|
||||
Psycopg
|
||||
-------
|
||||
|
||||
Starting from `psycopg2-2.8`_ the binary version of psycopg2 will no longer be installed by default. Installing it from
|
||||
the source code requires C compiler and postgres+python dev packages. Since in the python world it is not possible to
|
||||
specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
|
||||
|
||||
There are a few options available:
|
||||
|
||||
1. Use the package manager from your distro
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
|
||||
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
|
||||
|
||||
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the :ref:`list of dependencies <extras>` when installing Patroni with pip.
|
||||
|
||||
|
||||
.. _extras:
|
||||
|
||||
General installation for pip
|
||||
----------------------------
|
||||
|
||||
Patroni can be installed with pip:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install patroni[dependencies]
|
||||
|
||||
where ``dependencies`` can be either empty, or consist of one or more of the following:
|
||||
|
||||
etcd or etcd3
|
||||
`python-etcd` module in order to use Etcd as Distributed Configuration Store (DCS)
|
||||
consul
|
||||
`python-consul` module in order to use Consul as DCS
|
||||
zookeeper
|
||||
`kazoo` module in order to use Zookeeper as DCS
|
||||
exhibitor
|
||||
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
|
||||
kubernetes
|
||||
`kubernetes` module in order to use Kubernetes as DCS in Patroni
|
||||
raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto3` in order to use AWS callbacks
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg
|
||||
`psycopg[binary]>=3.0.0` module
|
||||
psycopg2
|
||||
`psycopg2>=2.5.4` module
|
||||
psycopg2-binary
|
||||
`psycopg2-binary` module
|
||||
|
||||
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install patroni[psycopg3,etcd3,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
|
||||
independently of Patroni.
|
||||
|
||||
.. _package_installation:
|
||||
|
||||
Package installation on Linux
|
||||
-----------------------------
|
||||
|
||||
Patroni packages may be available for your operating system, produced by the Postgres community for:
|
||||
|
||||
* RHEL, RockyLinux, AlmaLinux;
|
||||
* Debian and Ubuntu;
|
||||
* SUSE Enterprise Linux.
|
||||
|
||||
You can also find packages for direct dependencies of Patroni, like python modules that might not be available in
|
||||
the official operating system repositories.
|
||||
|
||||
For more information see the `PGDG repository`_ documentation.
|
||||
|
||||
If you are on a RedHat Enterprise Linux derivative operating system you may also require packages from EPEL, see
|
||||
`EPEL repository`_ documentation.
|
||||
|
||||
Once you have installed the PGDG repository for your OS you can install patroni.
|
||||
|
||||
.. note::
|
||||
|
||||
Patroni packages are not maintained by the Patroni developers, but rather by the Postgres community. If you
|
||||
require support please first try connecting on `Postgres slack`_.
|
||||
|
||||
Installing on Debian derivatives
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
With PGDG repo installed, see :ref:`above <package_installation>`, install Patroni via apt run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
apt-get install patroni
|
||||
|
||||
Installing on RedHat derivatives
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
With PGDG repo installed, see :ref:`above <package_installation>`, install patroni with an etcd DCS via dnf on RHEL 9
|
||||
(and derivatives) run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
dnf install patroni patroni-etcd
|
||||
|
||||
You can install etcd from PGDG if your RedHat derivative distribution does not provide packages. On the nodes that will
|
||||
host the DCS run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
dnf install 'dnf-command(config-manager)'
|
||||
dnf config-manager --enable pgdg-rhel9-extras
|
||||
dnf install etcd
|
||||
|
||||
You can replace the version of RHEL with `8` in the repo to make `pgdg-rhel8-extras` if needed. The repo name is still
|
||||
`pgdg-rhelN-extras` on RockyLinux, AlmaLinux, Oracle Linux, etc...
|
||||
|
||||
Installing on SUSE Enterprise Linux
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You might need to enable the SUSE PackageHub repositories for some dependencies. see `SUSE PackageHub`_ documentation.
|
||||
|
||||
For SLES 15 with PGDG repo installed, see :ref:`above <package_installation>`, you can install patroni using:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
zypper install patroni patroni-etcd
|
||||
|
||||
With the SUSE PackageHub repo enabled you can also install etcd:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
SUSEConnect -p PackageHub/15.5/x86_64
|
||||
zypper install etcd
|
||||
|
||||
Upgrading
|
||||
---------
|
||||
|
||||
Upgrading patroni is a very simple process, just update the software installation and restart the Patroni daemon on
|
||||
each node in the cluster.
|
||||
|
||||
However, restarting the Patroni daemon will result in a Postgres database restart. In some situations this may cause
|
||||
a failover of the primary node in your cluster, therefore it is recommended to put the cluster into maintenance mode
|
||||
until the Patroni daemon restart has been completed.
|
||||
|
||||
To put the cluster in maintenance mode, run the following command on one of the patroni nodes:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
patronictl pause --wait
|
||||
|
||||
Then on each node in the cluster, perform the package upgrade required for your OS:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
apt-get update && apt-get install patroni patroni-etcd
|
||||
|
||||
Restart the patroni daemon process on each node:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
systemctl restart patroni
|
||||
|
||||
Then finally resume monitoring of Postgres with patroni to take it out of maintenance mode:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
patronictl resume --wait
|
||||
|
||||
The cluster will now be full operational with the new version of Patroni.
|
||||
|
||||
.. _psycopg2-2.8: http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/
|
||||
.. _PGDG repository: https://www.postgresql.org/download/linux/
|
||||
.. _EPEL repository: https://docs.fedoraproject.org/en-US/epel/
|
||||
.. _SUSE PackageHub: https://packagehub.suse.com/how-to-use/
|
||||
.. _Postgres slack: http://pgtreats.info/slack-invite
|
||||
@@ -32,6 +32,58 @@ Configuration
|
||||
|
||||
Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment variables <kubernetes_environment>` are described in the general chapters of the documentation.
|
||||
|
||||
.. _kubernetes_role_values:
|
||||
|
||||
Customize role label
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
By default, Patroni will set corresponding labels on the pod it runs in based on node's role, such as ``role=master``.
|
||||
The key and value of label can be customized by `kubernetes.role_label`, `kubernetes.leader_label_value`, `kubernetes.follower_label_value` and `kubernetes.standby_leader_label_value`.
|
||||
|
||||
Note that if you migrate from default role labels to custom ones, you can reduce downtime by following migration steps:
|
||||
|
||||
1. Add a temporary label using original role value for the pod with `kubernetes.tmp_role_label` (like ``tmp_role``). Once pods are restarted they will get following labels set by Patroni:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: master
|
||||
tmp_role: master
|
||||
|
||||
2. After all pods have been updated, modify the service selector to select the temporary label.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
selector:
|
||||
cluster-name: foo
|
||||
tmp_role: master
|
||||
|
||||
3. Add your custom role label (e.g., set `kubernetes.leader_label_value=primary`). Once pods are restarted they will get following new labels set by Patroni:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
tmp_role: master
|
||||
|
||||
4. After all pods have been updated again, modify the service selector to use new role value.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
selector:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
|
||||
5. Finally, remove the temporary label from your configuration and update all pods.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
.. _patroni_configuration:
|
||||
|
||||
Patroni configuration
|
||||
=====================
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
|
||||
dynamic_configuration
|
||||
yaml_configuration
|
||||
ENVIRONMENT
|
||||
|
||||
|
||||
There are 3 types of Patroni configuration:
|
||||
|
||||
- Global :ref:`dynamic configuration <dynamic_configuration>`.
|
||||
These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
|
||||
Dynamic configuration can be set at any time using :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
If the options changed are not part of the startup configuration, they are applied asynchronously (upon the next wake up cycle)
|
||||
to every node, which gets subsequently reloaded.
|
||||
If the node requires a restart to apply the configuration (for `PostgreSQL parameters <https://www.postgresql.org/docs/current/view-pg-settings.html>`__ with context postmaster, if their values
|
||||
have changed), a special flag ``pending_restart`` indicating this is set in the members.data JSON.
|
||||
Additionally, the node status indicates this by showing ``"restart_pending": true``.
|
||||
|
||||
- Local :ref:`configuration file <yaml_configuration>` (patroni.yml).
|
||||
These options are defined in the configuration file and take precedence over dynamic configuration.
|
||||
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing :ref:`patronictl_reload`. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
|
||||
|
||||
- :ref:`Environment configuration <environment>`.
|
||||
It is possible to set/override some of the "Local" configuration parameters with environment variables.
|
||||
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
|
||||
|
||||
.. _important_configuration_rules:
|
||||
|
||||
Important rules
|
||||
---------------
|
||||
|
||||
PostgreSQL parameters controlled by Patroni
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Some of the PostgreSQL parameters **must hold the same values on the primary and the replicas**. For those, **values set either in the local patroni configuration files or via the environment variables take no effect**. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
|
||||
|
||||
- **max_connections**: 100
|
||||
- **max_locks_per_transaction**: 64
|
||||
- **max_worker_processes**: 8
|
||||
- **max_prepared_transactions**: 0
|
||||
- **wal_level**: hot_standby
|
||||
- **track_commit_timestamp**: off
|
||||
|
||||
For the parameters below, PostgreSQL does not require equal values among the primary and all the replicas. However, considering the possibility of a replica to become the primary at any time, it doesn't really make sense to set them differently; therefore, **Patroni restricts setting their values to the** :ref:`dynamic configuration <dynamic_configuration>`.
|
||||
|
||||
- **max_wal_senders**: 5
|
||||
- **max_replication_slots**: 5
|
||||
- **wal_keep_segments**: 8
|
||||
- **wal_keep_size**: 128MB
|
||||
|
||||
These parameters are validated to ensure they are sane, or meet a minimum value.
|
||||
|
||||
There are some other Postgres parameters controlled by Patroni:
|
||||
|
||||
- **listen_addresses** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- **port** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- **cluster_name** - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
|
||||
- **hot_standby: on**
|
||||
- **wal_log_hints: on** - for Postgres 9.4 and newer.
|
||||
|
||||
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
|
||||
|
||||
There also are some parameters like **postgresql.listen**, **postgresql.data_dir** that **can be set only locally**, i.e. in the Patroni :ref:`config file <yaml_configuration>` or via :ref:`configuration <environment>` variable. In most cases the local configuration will override the dynamic configuration.
|
||||
|
||||
|
||||
When applying the local or dynamic configuration options, the following actions are taken:
|
||||
|
||||
- The node first checks if there is a `postgresql.base.conf` file or if the ``custom_conf`` parameter is set.
|
||||
- If the ``custom_conf`` parameter is set, the file it specifies is used as the base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
|
||||
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and is used as the base configuration.
|
||||
- If there is no ``custom_conf`` nor `postgresql.base.conf`, the original `postgresql.conf` is renamed to `postgresql.base.conf` and used as the base configuration.
|
||||
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf` and an include is set in
|
||||
`postgresql.conf` to the base configuration (either `postgresql.base.conf` or the file at ``custom_conf``).
|
||||
Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present or not.
|
||||
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
|
||||
- If an option that requires restart is changed (we should look at the context in pg_settings and at the actual
|
||||
values of those options), a pending_restart flag is set on that node. This flag is reset on any restart.
|
||||
|
||||
The parameters would be applied in the following order (run-time are given the highest priority):
|
||||
|
||||
1. load parameters from file `postgresql.base.conf` (or from a ``custom_conf`` file, if set)
|
||||
2. load parameters from file `postgresql.conf`
|
||||
3. load parameters from file `postgresql.auto.conf`
|
||||
4. run-time parameter using `-o --name=value`
|
||||
|
||||
This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
|
||||
|
||||
.. _shared_memory_gucs:
|
||||
|
||||
PostgreSQL parameters that touch shared memory
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
PostgreSQL has some parameters that determine the size of the shared memory used by them:
|
||||
|
||||
- **max_connections**
|
||||
- **max_prepared_transactions**
|
||||
- **max_locks_per_transaction**
|
||||
- **max_wal_senders**
|
||||
- **max_worker_processes**
|
||||
|
||||
Changing these parameters require a PostgreSQL restart to take effect, and their shared memory structures cannot be smaller on the standby nodes than on the primary node.
|
||||
|
||||
As explained before, Patroni restrict changing their values through :ref:`dynamic configuration <dynamic_configuration>`, which usually consists of:
|
||||
|
||||
1. Applying changes through :ref:`patronictl_edit_config` (or via REST API ``/config`` endpoint)
|
||||
2. Restarting nodes through :ref:`patronictl_restart` (or via REST API ``/restart`` endpoint)
|
||||
|
||||
**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through :ref:`patronictl_restart` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node.
|
||||
|
||||
However, as those settings manage shared memory, some extra care should be taken when restarting the nodes:
|
||||
|
||||
* If you want to **increase** the value of any of those settings:
|
||||
|
||||
1. Restart all standbys first
|
||||
2. Restart the primary after that
|
||||
|
||||
* If you want to **decrease** the value of any of those settings:
|
||||
|
||||
1. Restart the primary first
|
||||
2. Restart all standbys after that
|
||||
|
||||
**Note:** if you attempt to restart all nodes in one go after **decreasing** the value of any of those settings, Patroni will ignore the change and restart the standby with the original setting value, thus requiring that you restart the standbys again later. Patroni does that to prevent the standby to enter in an infinite crash loop, because PostgreSQL quits with a `FATAL` message if you attempt to set any of those parameters to a value lower than what is visible in ``pg_controldata`` on the Standby node. In other words, we can only decrease the setting on the standby once its ``pg_controldata`` is up-to-date with the primary in regards to these changes on the primary.
|
||||
|
||||
More information about that can be found at `PostgreSQL Administrator's Overview <https://www.postgresql.org/docs/current/hot-standby.html#HOT-STANDBY-ADMIN>`__.
|
||||
|
||||
Patroni configuration parameters
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Also the following Patroni configuration options **can be changed only dynamically**:
|
||||
|
||||
- **ttl**: 30
|
||||
- **loop_wait**: 10
|
||||
- **retry_timeouts**: 10
|
||||
- **maximum_lag_on_failover**: 1048576
|
||||
- **max_timelines_history**: 0
|
||||
- **check_timeline**: false
|
||||
- **postgresql.use_slots**: true
|
||||
|
||||
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values.
|
||||
|
||||
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
|
||||
|
||||
|
||||
.. _validate_generate_config:
|
||||
|
||||
Configuration generation and validation
|
||||
---------------------------------------
|
||||
|
||||
Patroni provides command-line interfaces for a Patroni :ref:`local configuration <yaml_configuration>` generation and validation. Using the ``patroni`` executable you can:
|
||||
|
||||
- Create a sample local Patroni configuration;
|
||||
- Create a Patroni configuration file for the locally running PostgreSQL instance (e.g. as a preparation step for the :ref:`Patroni integration <existing_data>`);
|
||||
- Validate a given Patroni configuration file.
|
||||
|
||||
.. _generate_sample_config:
|
||||
|
||||
Sample Patroni configuration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --generate-sample-config [configfile]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
|
||||
Generate a sample Patroni configuration file in ``yaml`` format.
|
||||
Parameter values are defined using the :ref:`Environment configuration <environment>`, otherwise, if not set, the defaults used in Patroni or the ``#FIXME`` string for the values that should be later defined by the user.
|
||||
|
||||
Some default values are defined based on the local setup:
|
||||
|
||||
- **postgresql.listen**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
|
||||
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
|
||||
- **postgresql.authentication.rewind**: is only defined if the PostgreSQL version can be defined from the binary and the version is 11 or later.
|
||||
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
|
||||
Parameters
|
||||
""""""""""
|
||||
|
||||
``configfile`` - full path to the configuration file used to store the result. If not provided, the result is sent to ``stdout``.
|
||||
|
||||
.. _generate_config:
|
||||
|
||||
Patroni configuration for a running instance
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --generate-config [--dsn DSN] [configfile]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
|
||||
Generate a Patroni configuration in ``yaml`` format for the locally running PostgreSQL instance.
|
||||
Either the provided DSN (takes precedence) or PostgreSQL `environment variables <https://www.postgresql.org/docs/current/libpq-envars.html>`__ will be used for the PostgreSQL connection. If the password is not provided, it should be entered via prompt.
|
||||
|
||||
All the non-internal GUCs defined in the source Postgres instance, independently if they were set through a configuration file, through the postmaster command-line, or through environment variables, will be used as the source for the following Patroni configuration parameters:
|
||||
|
||||
- **scope**: ``cluster_name`` GUC value;
|
||||
- **postgresql.listen**: ``listen_addresses`` and ``port`` GUC values;
|
||||
- **postgresql.datadir**: ``data_directory`` GUC value;
|
||||
- **postgresql.parameters**: ``archive_command``, ``restore_command``, ``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``, ``config_file`` GUC values;
|
||||
- **bootstrap.dcs**: all other gathered PostgreSQL GUCs.
|
||||
|
||||
If ``scope``, ``postgresql.listen`` or ``postgresql.datadir`` is not set from the Postgres GUCs, the respective :ref:`Environment configuration <environment>` value is used.
|
||||
|
||||
Other rules applied for the values definition:
|
||||
|
||||
- **name**: ``PATRONI_NAME`` environment variable value if set, otherwise the current machine's hostname.
|
||||
- **postgresql.bin_dir**: path to the Postgres binaries gathered from the running instance.
|
||||
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the port used for the instance connection or the ``port`` GUC value.
|
||||
- **postgresql.authentication.superuser**: the configuration used for the instance connection;
|
||||
- **postgresql.pg_hba**: the lines gathered from the source instance's ``hba_file``.
|
||||
- **postgresql.pg_ident**: the lines gathered from the source instance's ``ident_file``.
|
||||
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
|
||||
Other parameters defined using :ref:`Environment configuration <environment>` are also included into the configuration.
|
||||
|
||||
Parameters
|
||||
""""""""""
|
||||
|
||||
``configfile``
|
||||
Full path to the configuration file used to store the result. If not provided, result is sent to ``stdout``.
|
||||
|
||||
``dsn``
|
||||
Optional DSN string for the local PostgreSQL instance to get GUC values from.
|
||||
|
||||
|
||||
Validate Patroni configuration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --validate-config [configfile]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
|
||||
Validate the given Patroni configuration and print the information about the failed checks.
|
||||
|
||||
Parameters
|
||||
""""""""""
|
||||
|
||||
``configfile``
|
||||
Full path to the configuration file to check. If not given or file does not exist, will try to read from the ``PATRONI_CONFIG_VARIABLE`` environment variable or, if not set, from the :ref:`Patroni environment variables <environment>`.
|
||||
+1975
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -19,7 +19,7 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
|
||||
|
||||
- For the Postgres primary with the leader lock Patroni updates the lock. If the node with the leader lock stops being the primary (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
|
||||
|
||||
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running primary node.
|
||||
- Manual unscheduled restart, manual unscheduled failover/switchover and reinitialize are allowed. No scheduled action is allowed. Manual switchover is only allowed if the node to switch over to is specified.
|
||||
|
||||
- If 'parallel' primaries are detected by Patroni, it emits a warning, but does not demote the primary without the leader lock.
|
||||
|
||||
@@ -32,6 +32,6 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
|
||||
User guide
|
||||
----------
|
||||
|
||||
``patronictl`` supports ``pause`` and ``resume`` commands.
|
||||
``patronictl`` supports :ref:`pause <patronictl_pause>` and :ref:`resume <patronictl_resume>` commands.
|
||||
|
||||
One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
|
||||
|
||||
+776
-294
File diff suppressed because it is too large
Load Diff
@@ -43,16 +43,31 @@ in the configuration files, Patroni supplies two cluster-specific ones:
|
||||
|
||||
Passing these two additional flags can be disabled by setting a special ``no_params`` parameter to ``True``.
|
||||
|
||||
If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
|
||||
If the bootstrap script returns ``0``, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
|
||||
of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed,
|
||||
cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap.
|
||||
|
||||
If a ``recovery_conf`` block is defined in the same section as the custom bootstrap method, Patroni will generate a
|
||||
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
|
||||
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
|
||||
``recovery.conf`` before starting the newly bootstrapped instance (or set the recovery settings on Postgres configuration if
|
||||
running PostgreSQL >= 12).
|
||||
Typically, such recovery configuration should contain at least one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
|
||||
|
||||
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists.
|
||||
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you.
|
||||
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists (PostgreSQL <= 11).
|
||||
Similarly, in that case Patroni will not remove the existing ``recovery.signal`` or ``standby.signal`` if either exists, nor will it override the configured recovery settings (PostgreSQL >= 12).
|
||||
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate recovery configuration for you.
|
||||
|
||||
Besides that, any additional key/value pairs informed in the custom bootstrap method configuration will be passed as arguments to ``command`` in the format ``--name=value``. For example:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
bootstrap:
|
||||
method: <custom_bootstrap_method_name>
|
||||
<custom_bootstrap_method_name>:
|
||||
command: <path_to_custom_bootstrap_script>
|
||||
arg1: value1
|
||||
arg2: value2
|
||||
|
||||
Makes the configured ``command`` to be called additionally with ``--arg1=value1 --arg2=value2`` command-line arguments.
|
||||
|
||||
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
||||
|
||||
@@ -191,7 +206,7 @@ There is no further relationship between the standby cluster and the primary
|
||||
cluster it replicates from, in particular, they must not share the same DCS
|
||||
scope if they use the same DCS. They do not know anything else from each other
|
||||
apart from replication information. Also, the standby cluster is not being
|
||||
displayed in ``patronictl list`` or ``patronictl topology`` output on the
|
||||
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
|
||||
primary cluster.
|
||||
|
||||
For the sake of flexibility, you can specify methods of creating a replica and
|
||||
|
||||
@@ -49,7 +49,7 @@ on at least two nodes, enable ``synchronous_mode_strict`` in addition to the
|
||||
``synchronous_mode``. This parameter prevents Patroni from switching off the
|
||||
synchronous replication on the primary when no synchronous standby candidates
|
||||
are available. As a downside, the primary is not be available for writes
|
||||
(unless the Postgres transaction explicitly turns of ``synchronous_mode``),
|
||||
(unless the Postgres transaction explicitly turns off ``synchronous_mode``),
|
||||
blocking all client write requests until at least one synchronous replica comes
|
||||
up.
|
||||
|
||||
|
||||
+359
-47
@@ -3,7 +3,7 @@
|
||||
Patroni REST API
|
||||
================
|
||||
|
||||
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
|
||||
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
|
||||
|
||||
Health check endpoints
|
||||
----------------------
|
||||
@@ -30,7 +30,8 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
||||
|
||||
- ``GET /replica?tag_key1=value1&tag_key2=value2``: replica check endpoint. In addition, It will also check for user defined tags ``key1`` and ``key2`` and their respective values in the **tags** section of the yaml configuration management. If the tag isn't defined for an instance, or if the value in the yaml configuration doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
|
||||
In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored.
|
||||
In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored.
|
||||
|
||||
- ``GET /?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /leader?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /primary?tag_key1=value1&tag_key2=value2``
|
||||
@@ -91,26 +92,263 @@ Monitoring endpoint
|
||||
|
||||
The ``GET /patroni`` is used by Patroni during the leader race. It also could be used by your monitoring system. The JSON document produced by this endpoint has the same structure as the JSON produced by the health check endpoints.
|
||||
|
||||
**Example:** A healthy cluster
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2019-09-24 09:22:32.555 CEST",
|
||||
"postmaster_start_time": "2023-08-18 11:03:37.966359+00:00",
|
||||
"role": "master",
|
||||
"server_version": 110005,
|
||||
"cluster_unlocked": false,
|
||||
"server_version": 150004,
|
||||
"xlog": {
|
||||
"location": 25624640
|
||||
"location": 67395656
|
||||
},
|
||||
"timeline": 3,
|
||||
"database_system_identifier": "6739877027151648096",
|
||||
"timeline": 1,
|
||||
"replication": [
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni2",
|
||||
"client_addr": "10.89.0.6",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
},
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni3",
|
||||
"client_addr": "10.89.0.2",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
}
|
||||
],
|
||||
"dcs_last_seen": 1692356718,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "1.6.0",
|
||||
"scope": "batman"
|
||||
"version": "3.1.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
}
|
||||
|
||||
**Example:** An unlocked cluster
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
|
||||
"role": "replica",
|
||||
"server_version": 150004,
|
||||
"xlog": {
|
||||
"received_location": 67419744,
|
||||
"replayed_location": 67419744,
|
||||
"replayed_timestamp": null,
|
||||
"paused": false
|
||||
},
|
||||
"timeline": 1,
|
||||
"replication": [
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni2",
|
||||
"client_addr": "10.89.0.6",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
},
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni3",
|
||||
"client_addr": "10.89.0.2",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
}
|
||||
],
|
||||
"cluster_unlocked": true,
|
||||
"dcs_last_seen": 1692356928,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
}
|
||||
|
||||
**Example:** An unlocked cluster with :ref:`DCS failsafe mode <dcs_failsafe_mode>` enabled
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
|
||||
"role": "replica",
|
||||
"server_version": 150004,
|
||||
"xlog": {
|
||||
"location": 67420024
|
||||
},
|
||||
"timeline": 1,
|
||||
"replication": [
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni2",
|
||||
"client_addr": "10.89.0.6",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
},
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni3",
|
||||
"client_addr": "10.89.0.2",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
}
|
||||
],
|
||||
"cluster_unlocked": true,
|
||||
"failsafe_mode_is_active": true,
|
||||
"dcs_last_seen": 1692356928,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
}
|
||||
|
||||
**Example:** A cluster with the :ref:`pause mode <pause>` enabled
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
|
||||
"role": "replica",
|
||||
"server_version": 150004,
|
||||
"xlog": {
|
||||
"location": 67420024
|
||||
},
|
||||
"timeline": 1,
|
||||
"replication": [
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni2",
|
||||
"client_addr": "10.89.0.6",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
},
|
||||
{
|
||||
"usename": "replicator",
|
||||
"application_name": "patroni3",
|
||||
"client_addr": "10.89.0.2",
|
||||
"state": "streaming",
|
||||
"sync_state": "async",
|
||||
"sync_priority": 0
|
||||
}
|
||||
],
|
||||
"pause": true,
|
||||
"dcs_last_seen": 1692356928,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
}
|
||||
|
||||
Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` endpoint.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl http://localhost:8008/metrics
|
||||
|
||||
# HELP patroni_version Patroni semver without periods. \
|
||||
# TYPE patroni_version gauge
|
||||
patroni_version{scope="batman",name="patroni1"} 020103
|
||||
# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.
|
||||
# TYPE patroni_postgres_running gauge
|
||||
patroni_postgres_running{scope="batman",name="patroni1"} 1
|
||||
# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.
|
||||
# TYPE patroni_postmaster_start_time gauge
|
||||
patroni_postmaster_start_time{scope="batman",name="patroni1"} 1657656955.179243
|
||||
# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.
|
||||
# TYPE patroni_master gauge
|
||||
patroni_master{scope="batman",name="patroni1"} 1
|
||||
# HELP patroni_primary Value is 1 if this node is the leader, 0 otherwise.
|
||||
# TYPE patroni_primary gauge
|
||||
patroni_primary{scope="batman",name="patroni1"} 1
|
||||
# HELP patroni_xlog_location Current location of the Postgres transaction log, 0 if this node is not the leader.
|
||||
# TYPE patroni_xlog_location counter
|
||||
patroni_xlog_location{scope="batman",name="patroni1"} 22320573386952
|
||||
# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.
|
||||
# TYPE patroni_standby_leader gauge
|
||||
patroni_standby_leader{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.
|
||||
# TYPE patroni_replica gauge
|
||||
patroni_replica{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.
|
||||
# TYPE patroni_sync_standby gauge
|
||||
patroni_sync_standby{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica.
|
||||
# TYPE patroni_xlog_received_location counter
|
||||
patroni_xlog_received_location{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_xlog_replayed_location Current location of the replayed Postgres transaction log, 0 if this node is not a replica.
|
||||
# TYPE patroni_xlog_replayed_location counter
|
||||
patroni_xlog_replayed_location{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed Postgres transaction log, 0 if null.
|
||||
# TYPE patroni_xlog_replayed_timestamp gauge
|
||||
patroni_xlog_replayed_timestamp{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.
|
||||
# TYPE patroni_xlog_paused gauge
|
||||
patroni_xlog_paused{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_postgres_streaming Value is 1 if Postgres is streaming, 0 otherwise.
|
||||
# TYPE patroni_postgres_streaming gauge
|
||||
patroni_postgres_streaming{scope="batman",name="patroni1"} 1
|
||||
# HELP patroni_postgres_in_archive_recovery Value is 1 if Postgres is replicating from archive, 0 otherwise.
|
||||
# TYPE patroni_postgres_in_archive_recovery gauge
|
||||
patroni_postgres_in_archive_recovery{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.
|
||||
# TYPE patroni_postgres_server_version gauge
|
||||
patroni_postgres_server_version{scope="batman",name="patroni1"} 140004
|
||||
# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.
|
||||
# TYPE patroni_cluster_unlocked gauge
|
||||
patroni_cluster_unlocked{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.
|
||||
# TYPE patroni_postgres_timeline counter
|
||||
patroni_failsafe_mode_is_active{scope="batman",name="patroni1"} 0
|
||||
# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.
|
||||
# TYPE patroni_postgres_timeline counter
|
||||
patroni_postgres_timeline{scope="batman",name="patroni1"} 24
|
||||
# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully by Patroni.
|
||||
# TYPE patroni_dcs_last_seen gauge
|
||||
patroni_dcs_last_seen{scope="batman",name="patroni1"} 1677658321
|
||||
# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.
|
||||
# TYPE patroni_pending_restart gauge
|
||||
patroni_pending_restart{scope="batman",name="patroni1"} 1
|
||||
# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.
|
||||
# TYPE patroni_is_paused gauge
|
||||
patroni_is_paused{scope="batman",name="patroni1"} 1
|
||||
|
||||
|
||||
Cluster status endpoints
|
||||
------------------------
|
||||
|
||||
@@ -122,24 +360,24 @@ Cluster status endpoints
|
||||
{
|
||||
"members": [
|
||||
{
|
||||
"name": "postgresql0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 5432,
|
||||
"name": "patroni1",
|
||||
"role": "leader",
|
||||
"state": "running",
|
||||
"api_url": "http://127.0.0.1:8008/patroni",
|
||||
"api_url": "http://10.89.0.4:8008/patroni",
|
||||
"host": "10.89.0.4",
|
||||
"port": 5432,
|
||||
"timeline": 5,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "postgresql1",
|
||||
"host": "127.0.0.1",
|
||||
"port": 5433,
|
||||
"name": "patroni2",
|
||||
"role": "replica",
|
||||
"state": "running",
|
||||
"api_url": "http://127.0.0.1:8009/patroni",
|
||||
"state": "streaming",
|
||||
"api_url": "http://10.89.0.6:8008/patroni",
|
||||
"host": "10.89.0.6",
|
||||
"port": 5433,
|
||||
"timeline": 5,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
@@ -147,9 +385,11 @@ Cluster status endpoints
|
||||
"lag": 0
|
||||
}
|
||||
],
|
||||
"scope": "demo",
|
||||
"scheduled_switchover": {
|
||||
"at": "2019-09-24T10:36:00+02:00",
|
||||
"from": "postgresql0"
|
||||
"at": "2023-09-24T10:36:00+02:00",
|
||||
"from": "patroni1",
|
||||
"to": "patroni3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +426,7 @@ Cluster status endpoints
|
||||
]
|
||||
]
|
||||
|
||||
.. _config_endpoint:
|
||||
|
||||
Config endpoint
|
||||
---------------
|
||||
@@ -194,7 +435,7 @@ Config endpoint
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s localhost:8008/config | jq .
|
||||
$ curl -s http://localhost:8008/config | jq .
|
||||
{
|
||||
"ttl": 30,
|
||||
"loop_wait": 10,
|
||||
@@ -205,7 +446,6 @@ Config endpoint
|
||||
"use_pg_rewind": true,
|
||||
"parameters": {
|
||||
"hot_standby": "on",
|
||||
"wal_log_hints": "on",
|
||||
"wal_level": "hot_standby",
|
||||
"max_wal_senders": 5,
|
||||
"max_replication_slots": 5,
|
||||
@@ -232,7 +472,6 @@ Config endpoint
|
||||
"use_pg_rewind": true,
|
||||
"parameters": {
|
||||
"hot_standby": "on",
|
||||
"wal_log_hints": "on",
|
||||
"wal_level": "hot_standby",
|
||||
"max_wal_senders": 5,
|
||||
"max_replication_slots": 5,
|
||||
@@ -256,8 +495,9 @@ Let's check that the node processed this configuration. First of all it should s
|
||||
"location": 2197818976
|
||||
},
|
||||
"patroni": {
|
||||
"version": "1.0",
|
||||
"scope": "batman",
|
||||
"version": "1.0"
|
||||
"name": "patroni1"
|
||||
},
|
||||
"state": "running",
|
||||
"role": "master",
|
||||
@@ -285,7 +525,6 @@ If you want to remove (reset) some setting just patch it with ``null``:
|
||||
"hot_standby": "on",
|
||||
"unix_socket_directories": ".",
|
||||
"wal_level": "hot_standby",
|
||||
"wal_log_hints": "on",
|
||||
"max_wal_senders": 5,
|
||||
"max_replication_slots": 5
|
||||
}
|
||||
@@ -299,7 +538,7 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s -XPUT -d \
|
||||
'{"maximum_lag_on_failover":1048576,"retry_timeout":10,"postgresql":{"use_slots":true,"use_pg_rewind":true,"parameters":{"hot_standby":"on","wal_log_hints":"on","wal_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
|
||||
'{"maximum_lag_on_failover":1048576,"retry_timeout":10,"postgresql":{"use_slots":true,"use_pg_rewind":true,"parameters":{"hot_standby":"on","wal_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
|
||||
http://localhost:8008/config | jq .
|
||||
{
|
||||
"ttl": 20,
|
||||
@@ -311,7 +550,6 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
|
||||
"hot_standby": "on",
|
||||
"unix_socket_directories": ".",
|
||||
"wal_level": "hot_standby",
|
||||
"wal_log_hints": "on",
|
||||
"max_wal_senders": 5
|
||||
},
|
||||
"use_pg_rewind": true
|
||||
@@ -323,41 +561,114 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
|
||||
Switchover and failover endpoints
|
||||
---------------------------------
|
||||
|
||||
``POST /switchover`` or ``POST /failover``. These endpoints are very similar to each other. There are a couple of minor differences though:
|
||||
.. _switchover_api:
|
||||
|
||||
1. The failover endpoint allows to perform a manual failover when there are no healthy nodes, but at the same time it will not allow you to schedule a switchover.
|
||||
Switchover
|
||||
^^^^^^^^^^
|
||||
|
||||
2. The switchover endpoint is the opposite. It works only when the cluster is healthy (there is a leader) and allows to schedule a switchover at a given time.
|
||||
``/switchover`` endpoint only works when the cluster is healthy (there is a leader). It also allows to schedule a switchover at a given time.
|
||||
|
||||
When calling ``/switchover`` endpoint a candidate can be specified but is not required, in contrast to ``/failover`` endpoint. If a candidate is not provided, all the eligible nodes of the cluster will participate in the leader race after the leader stepped down.
|
||||
|
||||
In the JSON body of the ``POST`` request you must specify at least the ``leader`` or ``candidate`` fields and optionally the ``scheduled_at`` field if you want to schedule a switchover at a specific time.
|
||||
In the JSON body of the ``POST`` request you must specify the ``leader`` field. The ``candidate`` and the ``scheduled_at`` fields are optional and can be used to schedule a switchover at a specific time.
|
||||
|
||||
Depending on the situation, requests might return different HTTP status codes and bodies. Status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412**, or **503**) will be returned with some details in the response body.
|
||||
|
||||
Example: perform a failover to the specific node:
|
||||
``DELETE /switchover`` can be used to delete the currently scheduled switchover.
|
||||
|
||||
**Example:** perform a switchover to any healthy standby
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8009/failover -XPOST -d '{"candidate":"postgresql1"}'
|
||||
Successfully failed over to "postgresql1"
|
||||
$ curl -s http://localhost:8008/switchover -XPOST -d '{"leader":"postgresql1"}'
|
||||
Successfully switched over to "postgresql2"
|
||||
|
||||
|
||||
Example: schedule a switchover from the leader to any other healthy replica in the cluster at a specific time:
|
||||
**Example:** perform a switchover to a specific node
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/switchover -XPOST -d \
|
||||
'{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}'
|
||||
Switchover scheduled
|
||||
$ curl -s http://localhost:8008/switchover -XPOST -d \
|
||||
'{"leader":"postgresql1","candidate":"postgresql2"}'
|
||||
Successfully switched over to "postgresql2"
|
||||
|
||||
|
||||
Depending on the situation the request might finish with a different HTTP status code and body. The status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412** or **503**) will be returned with some details in the response body. For more information please check the source code of ``patroni/api.py:do_POST_failover()`` method.
|
||||
**Example:** schedule a switchover from the leader to any other healthy standby in the cluster at a specific time.
|
||||
|
||||
- ``DELETE /switchover``: delete the scheduled switchover
|
||||
.. code-block:: bash
|
||||
|
||||
The ``POST /switchover`` and ``POST failover`` endpoints are used by ``patronictl switchover`` and ``patronictl failover``, respectively.
|
||||
The ``DELETE /switchover`` is used by ``patronictl flush <cluster-name> switchover``.
|
||||
$ curl -s http://localhost:8008/switchover -XPOST -d \
|
||||
'{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}'
|
||||
Switchover scheduled
|
||||
|
||||
|
||||
Failover
|
||||
^^^^^^^^
|
||||
|
||||
``/failover`` endpoint can be used to perform a manual failover when there are no healthy nodes (e.g. to an asynchronous standby if all synchronous standbys are not healthy enough to promote). However there is no requirement for a cluster not to have leader - failover can also be run on a healthy cluster.
|
||||
|
||||
In the JSON body of the ``POST`` request you must specify the ``candidate`` field. If the ``leader`` field is specified, a switchover is triggered instead.
|
||||
|
||||
**Example:**
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/failover -XPOST -d '{"candidate":"postgresql1"}'
|
||||
Successfully failed over to "postgresql1"
|
||||
|
||||
.. warning::
|
||||
:ref:`Be very careful <failover_healthcheck>` when using this endpoint, as this can cause data loss in certain situations. In most cases, :ref:`the switchover endpoint <switchover_api>` satisfies the administrator's needs.
|
||||
|
||||
|
||||
``POST /switchover`` and ``POST /failover`` endpoints are used by :ref:`patronictl_switchover` and :ref:`patronictl_failover`, respectively.
|
||||
|
||||
``DELETE /switchover`` is used by :ref:`patronictl flush cluster-name switchover <patronictl_flush_parameters>`.
|
||||
|
||||
.. list-table:: Failover/Switchover comparison
|
||||
:widths: 25 25 25
|
||||
:header-rows: 1
|
||||
|
||||
* -
|
||||
- Failover
|
||||
- Switchover
|
||||
* - Requires leader specified
|
||||
- no
|
||||
- yes
|
||||
* - Requires candidate specified
|
||||
- yes
|
||||
- no
|
||||
* - Can be run in pause
|
||||
- yes
|
||||
- yes (only to a specific candidate)
|
||||
* - Can be scheduled
|
||||
- no
|
||||
- yes (if not in pause)
|
||||
|
||||
.. _failover_healthcheck:
|
||||
|
||||
Healthy standby
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
There are a couple of checks that a member of a cluster should pass to be able to participate in the leader race during a switchover or to become a leader as a failover/switchover candidate:
|
||||
|
||||
- be reachable via Patroni API;
|
||||
- not have ``nofailover`` tag set to ``true``;
|
||||
- have watchdog fully functional (if required by the configuration);
|
||||
- in case of a switchover in a healthy cluster or an automatic failover, not exceed maximum replication lag (``maximum_lag_on_failover`` :ref:`configuration parameter <dynamic_configuration>`);
|
||||
- in case of a switchover in a healthy cluster or an automatic failover, not have a timeline number smaller than the cluster timeline if ``check_timeline`` :ref:`configuration parameter <dynamic_configuration>` is set to ``true``;
|
||||
- in :ref:`synchronous mode <synchronous_mode>`:
|
||||
|
||||
- In case of a switchover (both with and without a candidate): be listed in the ``/sync`` key members;
|
||||
- For a failover in both healthy and unhealthy clusters, this check is omitted.
|
||||
|
||||
.. warning::
|
||||
In case of a manual failover in a cluster without a leader, a candidate will be allowed to promote even if:
|
||||
- it is not in the ``/sync`` key members when synchronous mode is enabled;
|
||||
- its lag exceeds the maximum replication lag allowed;
|
||||
- it has the timeline number smaller than the last known cluster timeline.
|
||||
|
||||
.. _restart_endpoint:
|
||||
|
||||
Restart endpoint
|
||||
----------------
|
||||
|
||||
@@ -371,15 +682,16 @@ Restart endpoint
|
||||
|
||||
- ``DELETE /restart``: delete the scheduled restart
|
||||
|
||||
``POST /restart`` and ``DELETE /restart`` endpoints are used by ``patronictl restart`` and ``patronictl flush <cluster-name> restart`` respectively.
|
||||
``POST /restart`` and ``DELETE /restart`` endpoints are used by :ref:`patronictl_restart` and :ref:`patronictl flush cluster-name restart <patronictl_flush_parameters>` respectively.
|
||||
|
||||
.. _reload_endpoint:
|
||||
|
||||
Reload endpoint
|
||||
---------------
|
||||
|
||||
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of ``patronictl restart``.
|
||||
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of :ref:`patronictl_restart`.
|
||||
|
||||
The reload endpoint is used by ``patronictl reload``.
|
||||
The reload endpoint is used by :ref:`patronictl_reload`.
|
||||
|
||||
|
||||
Reinitialize endpoint
|
||||
@@ -389,4 +701,4 @@ Reinitialize endpoint
|
||||
|
||||
The call might fail if Patroni is in a loop trying to recover (restart) a failed Postgres. In order to overcome this problem one can specify ``{"force":true}`` in the request body.
|
||||
|
||||
The reinitialize endpoint is used by ``patronictl reinit``.
|
||||
The reinitialize endpoint is used by :ref:`patronictl_reinit`.
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@ A Patroni cluster has two interfaces to be protected from unauthorized access: t
|
||||
Protecting DCS
|
||||
==============
|
||||
|
||||
Patroni and patronictl both store and retrieve data to/from the DCS.
|
||||
Patroni and :ref:`patronictl` both store and retrieve data to/from the DCS.
|
||||
|
||||
Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
|
||||
|
||||
The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`SETTINGS <bootstrap_settings>`
|
||||
The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`settings <yaml_configuration>`.
|
||||
|
||||
The general recommendation is to enable TLS for all DCS communication.
|
||||
|
||||
@@ -22,7 +22,7 @@ Protecting the REST API
|
||||
|
||||
Protecting the REST API is a more complicated task.
|
||||
|
||||
The Patroni REST API is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
|
||||
The Patroni REST API is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
|
||||
|
||||
From the point of view of security, REST API contains safe (``GET`` requests, only retrieve information) and unsafe (``PUT``, ``POST``, ``PATCH`` and ``DELETE`` requests, change the state of nodes) endpoints.
|
||||
|
||||
@@ -32,6 +32,6 @@ When TLS for the REST API is enabled and a PKI is established, mutual authentica
|
||||
|
||||
The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``).
|
||||
|
||||
The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`SETTINGS <patronictl_settings>` for a detailed description of the TLS client parameters.
|
||||
The ``ctl`` section parameters enable TLS server authentication to the client (the :ref:`patronictl` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
|
||||
|
||||
Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
.. _yaml_configuration:
|
||||
|
||||
============================
|
||||
YAML Configuration Settings
|
||||
============================
|
||||
|
||||
|
||||
Global/Universal
|
||||
----------------
|
||||
- **name**: the name of the host. Must be unique for the cluster.
|
||||
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||
- **scope**: cluster name
|
||||
|
||||
Log
|
||||
---
|
||||
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
|
||||
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
|
||||
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
|
||||
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
|
||||
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
|
||||
- **file\_num**: The number of application logs to retain.
|
||||
- **file\_size**: Size of patroni.log file (in bytes) that triggers a log rolling.
|
||||
- **loggers**: This section allows redefining logging level per python module
|
||||
|
||||
- **patroni.postmaster: WARNING**
|
||||
- **urllib3: DEBUG**
|
||||
|
||||
.. _bootstrap_settings:
|
||||
|
||||
Bootstrap configuration
|
||||
-----------------------
|
||||
|
||||
.. note::
|
||||
Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future
|
||||
changes to the ``bootstrap.dcs`` section of the YAML configuration will not take any effect! If you want to change
|
||||
them please use either :ref:`patronictl_edit_config` or the Patroni :ref:`REST API <rest_api>`.
|
||||
|
||||
- **bootstrap**:
|
||||
|
||||
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of the given configuration store after initializing the new cluster. The global dynamic configuration for the cluster. You can put any of the parameters described in the :ref:`Dynamic Configuration settings <dynamic_configuration>` under ``bootstrap.dcs`` and after Patroni has initialized (bootstrapped) the new cluster, it will write this section into `/<namespace>/<scope>/config` of the configuration store.
|
||||
- **method**: custom script to use for bootstrapping this cluster.
|
||||
|
||||
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
|
||||
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
|
||||
parameter is present in the configuration file.
|
||||
- **initdb**: (optional) list options to be passed on to initdb.
|
||||
|
||||
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
|
||||
- **- encoding: UTF8**: default encoding for new databases.
|
||||
- **- locale: UTF8**: default locale for new databases.
|
||||
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
|
||||
|
||||
.. _citus_settings:
|
||||
|
||||
Citus
|
||||
-----
|
||||
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
|
||||
|
||||
- **group**: the Citus group id, integer. Use ``0`` for coordinator and ``1``, ``2``, etc... for workers
|
||||
- **database**: the database where ``citus`` extension should be created. Must be the same on the coordinator and all workers. Currently only one database is supported.
|
||||
|
||||
.. _consul_settings:
|
||||
|
||||
Consul
|
||||
------
|
||||
Most of the parameters are optional, but you have to specify one of the **host** or **url**
|
||||
|
||||
- **host**: the host:port for the Consul local agent.
|
||||
- **url**: url for the Consul local agent, in format: http(s)://host:port.
|
||||
- **port**: (optional) Consul port.
|
||||
- **scheme**: (optional) **http** or **https**, defaults to **http**.
|
||||
- **token**: (optional) ACL token.
|
||||
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests.
|
||||
- **cacert**: (optional) The ca certificate. If present it will enable validation.
|
||||
- **cert**: (optional) file with the client certificate.
|
||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
|
||||
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
|
||||
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
|
||||
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**.
|
||||
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``primary``/``replica``/``standby-leader``). By default an empty list is used.
|
||||
- **service\_check\_interval**: (optional) how often to perform health check against registered url. Defaults to '5s'.
|
||||
- **service\_check\_tls\_server\_name**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
|
||||
|
||||
The ``token`` needs to have the following ACL permissions:
|
||||
|
||||
::
|
||||
|
||||
service_prefix "${scope}" {
|
||||
policy = "write"
|
||||
}
|
||||
key_prefix "${namespace}/${scope}" {
|
||||
policy = "write"
|
||||
}
|
||||
session_prefix "" {
|
||||
policy = "write"
|
||||
}
|
||||
|
||||
Etcd
|
||||
----
|
||||
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
|
||||
|
||||
- **host**: the host:port for the etcd endpoint.
|
||||
- **hosts**: list of etcd endpoint in format host1:port1,host2:port2,etc... Could be a comma separated string or an actual yaml list.
|
||||
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
|
||||
- **url**: url for the etcd.
|
||||
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
|
||||
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
|
||||
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **username**: (optional) username for etcd authentication.
|
||||
- **password**: (optional) password for etcd authentication.
|
||||
- **cacert**: (optional) The ca certificate. If present it will enable validation.
|
||||
- **cert**: (optional) file with the client certificate.
|
||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||
|
||||
Etcdv3
|
||||
------
|
||||
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
|
||||
|
||||
.. warning::
|
||||
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
|
||||
|
||||
|
||||
ZooKeeper
|
||||
----------
|
||||
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
|
||||
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
|
||||
- **cacert**: (optional) The CA certificate. If present it will enable validation.
|
||||
- **cert**: (optional) File with the client certificate.
|
||||
- **key**: (optional) File with the client key.
|
||||
- **key_password**: (optional) The client key password.
|
||||
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||
|
||||
.. note::
|
||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||
|
||||
|
||||
Exhibitor
|
||||
---------
|
||||
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
|
||||
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor.
|
||||
- **port**: Exhibitor port.
|
||||
|
||||
.. _kubernetes_settings:
|
||||
|
||||
Kubernetes
|
||||
----------
|
||||
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
|
||||
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
|
||||
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
|
||||
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||
- **role\_label**: (optional) name of the label containing role (master or replica or other custom value). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
||||
- **leader\_label\_value**: (optional) value of the pod label when Postgres role is ``master``. Default value is ``master``.
|
||||
- **follower\_label\_value**: (optional) value of the pod label when Postgres role is ``replica``. Default value is ``replica``.
|
||||
- **standby\_leader\_label\_value**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``master``.
|
||||
- **tmp_\role\_label**: (optional) name of the temporary label containing role (master or replica). Value of this label will always use the default of corresponding role. Set only when necessary.
|
||||
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
|
||||
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
- **retriable\_http\_codes**: (optional) list of HTTP status codes from K8s API to retry on. By default Patroni is retrying on ``500``, ``503``, and ``504``, or if K8s API response has ``retry-after`` HTTP header.
|
||||
|
||||
|
||||
.. _raft_settings:
|
||||
|
||||
Raft (deprecated)
|
||||
-----------------
|
||||
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
|
||||
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
|
||||
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
|
||||
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
|
||||
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
|
||||
|
||||
Short FAQ about Raft implementation
|
||||
|
||||
- Q: How to list all the nodes providing consensus?
|
||||
|
||||
A: ``syncobj_admin -conn host:port -status`` where the host:port is the address of one of the cluster nodes
|
||||
|
||||
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
|
||||
|
||||
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
|
||||
|
||||
- Q: Where to get the ``syncobj_admin`` utility?
|
||||
|
||||
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
|
||||
|
||||
- Q: it is possible to run Patroni node without adding in to the consensus?
|
||||
|
||||
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
|
||||
|
||||
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
|
||||
|
||||
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
|
||||
|
||||
|
||||
.. _postgresql_settings:
|
||||
|
||||
PostgreSQL
|
||||
----------
|
||||
- **postgresql**:
|
||||
|
||||
- **authentication**:
|
||||
|
||||
- **superuser**:
|
||||
|
||||
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
|
||||
- **password**: password for the superuser, set during initialization (initdb).
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **replication**:
|
||||
|
||||
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access the replication source via streaming replication
|
||||
- **password**: replication password; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **rewind**:
|
||||
|
||||
- **username**: (optional) name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
|
||||
- **password**: (optional) password for the user for ``pg_rewind``; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
|
||||
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
|
||||
|
||||
- **on\_reload**: run this script when configuration reload is triggered.
|
||||
- **on\_restart**: run this script when the postgres restarts (without changing role).
|
||||
- **on\_role\_change**: run this script when the postgres is being promoted or demoted.
|
||||
- **on\_start**: run this script when the postgres starts.
|
||||
- **on\_stop**: run this script when the postgres stops.
|
||||
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- **proxy\_address**: IP address + port through which a connection pool (e.g. pgbouncer) running next to Postgres is accessible. The value is written to the member key in DCS as ``proxy_url`` and could be used/useful for service discovery.
|
||||
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
|
||||
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
|
||||
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
|
||||
- **data\_dir**: The location of the Postgres data directory, either :ref:`existing <existing_data>` or to be initialized by Patroni.
|
||||
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
|
||||
- **bin\_dir**: (optional) Path to PostgreSQL binaries (pg_ctl, initdb, pg_controldata, pg_basebackup, postgres, pg_isready, pg_rewind). If not provided or is an empty string, PATH environment variable will be used to find the executables.
|
||||
- **bin\_name**: (optional) Make it possible to override Postgres binary names, if you are using a custom Postgres distribution:
|
||||
|
||||
- **pg\_ctl**: (optional) Custom name for ``pg_ctl`` binary.
|
||||
- **initdb**: (optional) Custom name for ``initdb`` binary.
|
||||
- **pg\controldata**: (optional) Custom name for ``pg_controldata`` binary.
|
||||
- **pg\_basebackup**: (optional) Custom name for ``pg_basebackup`` binary.
|
||||
- **postgres**: (optional) Custom name for ``postgres`` binary.
|
||||
- **pg\_isready**: (optional) Custom name for ``pg_isready`` binary.
|
||||
- **pg\_rewind**: (optional) Custom name for ``pg_rewind`` binary.
|
||||
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <patroni_configuration>` for details.
|
||||
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. Patroni ignores this parameter if ``hba_file`` PostgreSQL parameter is set to a non-default value. Together with :ref:`dynamic configuration <dynamic_configuration>` this parameter simplifies management of ``pg_hba.conf``.
|
||||
|
||||
- **- host all all 0.0.0.0/0 md5**
|
||||
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
||||
- **pg\_ident**: list of lines that Patroni will use to generate ``pg_ident.conf``. Patroni ignores this parameter if ``ident_file`` PostgreSQL parameter is set to a non-default value. Together with :ref:`dynamic configuration <dynamic_configuration>` this parameter simplifies management of ``pg_ident.conf``.
|
||||
|
||||
- **- mapname1 systemname1 pguser1**
|
||||
- **- mapname1 systemname2 pguser2**
|
||||
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
|
||||
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
|
||||
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
|
||||
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former primary can not start streaming from the new primary. This option is useful when ``pg_rewind`` can not be used. While performing timelines divergence check on PostgreSQL v10 and older Patroni will try to connect with replication credential to the "postgres" database. Hence, such access should be allowed in the pg_hba.conf. Default value is **false**.
|
||||
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
||||
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
|
||||
- **before\_stop**: a script that executes immediately prior to stopping postgres. As opposed to a callback, this script runs synchronously, blocking shutdown until it has completed. The return code of this script does not impact whether shutdown proceeds afterwards.
|
||||
|
||||
.. _restapi_settings:
|
||||
|
||||
REST API
|
||||
--------
|
||||
- **restapi**:
|
||||
|
||||
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the leader is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
|
||||
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
|
||||
- **authentication**: (optional)
|
||||
|
||||
- **username**: Basic-auth username to protect unsafe REST API endpoints.
|
||||
- **password**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
|
||||
- **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
|
||||
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
|
||||
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
|
||||
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
|
||||
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
|
||||
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
- **request_queue_size**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
|
||||
|
||||
Here is an example of both **http_extra_headers** and **https_extra_headers**:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
restapi:
|
||||
listen: <listen>
|
||||
connect_address: <connect_address>
|
||||
authentication:
|
||||
username: <username>
|
||||
password: <password>
|
||||
http_extra_headers:
|
||||
'X-Frame-Options': 'SAMEORIGIN'
|
||||
'X-XSS-Protection': '1; mode=block'
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
cafile: <ca file>
|
||||
certfile: <cert>
|
||||
keyfile: <key>
|
||||
https_extra_headers:
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
|
||||
|
||||
.. warning::
|
||||
|
||||
- The ``restapi.connect_address`` must be accessible from all nodes of a given Patroni cluster. Internally Patroni is using it during the leader race to find nodes with minimal replication lag.
|
||||
- If you enabled client certificates validation (``restapi.verify_client`` is set to ``required``), you also **must** provide **valid client certificates** in the ``ctl.certfile``, ``ctl.keyfile``, ``ctl.keyfile_password``. If not provided, Patroni will not work correctly.
|
||||
|
||||
|
||||
.. _patronictl_settings:
|
||||
|
||||
CTL
|
||||
---
|
||||
- **ctl**: (optional)
|
||||
|
||||
- **authentication**:
|
||||
|
||||
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
|
||||
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
|
||||
- **insecure**: Allow connections to REST API without verifying SSL certs.
|
||||
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
|
||||
- **certfile**: Specifies the file with the client certificate in the PEM format.
|
||||
- **keyfile**: Specifies the file with the client secret key in the PEM format.
|
||||
- **keyfile\_password**: Specifies a password for decrypting the client keyfile.
|
||||
|
||||
Watchdog
|
||||
--------
|
||||
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
|
||||
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
|
||||
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
|
||||
|
||||
.. _tags_settings:
|
||||
|
||||
Tags
|
||||
----
|
||||
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
|
||||
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
||||
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
|
||||
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
|
||||
|
||||
.. warning::
|
||||
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
|
||||
|
||||
In addition to these predefined tags, you can also add your own ones:
|
||||
|
||||
- **key1**: ``true``
|
||||
- **key2**: ``false``
|
||||
- **key3**: ``1.4``
|
||||
- **key4**: ``"RandomString"``
|
||||
|
||||
Tags are visible in the :ref:`REST API <rest_api>` and :ref:`patronictl_list` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
@@ -0,0 +1,97 @@
|
||||
# syntax = docker/dockerfile:1.5
|
||||
# Used only for running tests using tox, see ../tox.ini
|
||||
ARG PG_MAJOR
|
||||
ARG PGHOME=/home/postgres
|
||||
ARG LC_ALL=C.UTF-8
|
||||
ARG LANG=C.UTF-8
|
||||
ARG BASE_IMAGE=postgres
|
||||
|
||||
FROM ${BASE_IMAGE}:${PG_MAJOR}
|
||||
|
||||
ARG PGHOME
|
||||
ARG LC_ALL
|
||||
ARG LANG
|
||||
|
||||
ENV PGHOME="$PGHOME"
|
||||
ENV PG_USER="${PG_USER:-postgres}"
|
||||
ENV PG_GROUP="${PG_GROUP:-$PG_USER}"
|
||||
ENV LC_ALL="$LC_ALL"
|
||||
ENV LANG="$LANG"
|
||||
|
||||
ARG ETCDVERSION=3.3.13
|
||||
ENV ETCDVERSION="$ETCDVERSION"
|
||||
ARG ETCDURL="https://github.com/coreos/etcd/releases/download/v$ETCDVERSION"
|
||||
|
||||
USER root
|
||||
RUN set -ex \
|
||||
&& apt-get update \
|
||||
&& apt-get reinstall init-system-helpers \
|
||||
&& apt-get install -y \
|
||||
python3-dev \
|
||||
python3-venv \
|
||||
rsync \
|
||||
curl \
|
||||
gcc \
|
||||
golang \
|
||||
jq \
|
||||
locales \
|
||||
sudo \
|
||||
busybox \
|
||||
net-tools \
|
||||
iputils-ping \
|
||||
&& rm -rf /var/cache/apt \
|
||||
\
|
||||
&& python3 -m venv /tox \
|
||||
&& /tox/bin/pip install --no-cache-dir tox>=4 \
|
||||
\
|
||||
&& mkdir -p "$PGHOME" \
|
||||
&& sed -i "s|/var/lib/postgresql.*|$PGHOME:/bin/bash|" /etc/passwd \
|
||||
&& chown -R "$PG_USER:$PG_GROUP" /var/log /home/postgres \
|
||||
\
|
||||
# Download etcd \
|
||||
&& curl -sL "$ETCDURL/etcd-v$ETCDVERSION-linux-$(dpkg --print-architecture).tar.gz" \
|
||||
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
|
||||
|
||||
ENV PATH="/tox/bin:$PATH"
|
||||
|
||||
# This Dockerfile syntax only works with docker buildx and the syntax
|
||||
# line at the top of this file.
|
||||
COPY <<EOF /tox-wrapper.sh
|
||||
#!/usr/bin/env bash
|
||||
set -ex
|
||||
copy_output() {
|
||||
if [[ -d "\$PGHOME/src/features/output" && /src/features ]] ;then
|
||||
cp -a "\$PGHOME/src/features/output" "/src/features/output-\$HOSTNAME"
|
||||
find "/src/features/output-\$HOSTNAME" -type f -exec chmod 666 {} \\;
|
||||
find "/src/features/output-\$HOSTNAME" -type d -exec chmod 777 {} \\;
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure the copy is ran if the container is stopped with `docker stop` or `docker kill`
|
||||
trap 'copy_output' SIGTERM
|
||||
# For architectures such as aarch we need to get the respective GOARCH
|
||||
# so we can tell etcd we're ok with running an unsupported architecture.
|
||||
export ETCD_UNSUPPORTED_ARCH=$(go env GOARCH)
|
||||
cd /src
|
||||
runuser -u "\$PG_USER" -- \\
|
||||
find . ! -readable 2>/dev/null \\
|
||||
| sed 's|^./||' >/tmp/copy_exclude.lst \\
|
||||
|| true
|
||||
runuser -u "\$PG_USER" -- \\
|
||||
rsync -a \\
|
||||
--exclude=.tox \\
|
||||
--exclude="features/output*" \\
|
||||
--exclude-from="/tmp/copy_exclude.lst" \\
|
||||
. "\$PGHOME/src/"
|
||||
cd "\$PGHOME/src"
|
||||
runuser -u "\$PG_USER" -w ETCD_UNSUPPORTED_ARCH -- "\$@" &
|
||||
wait $!
|
||||
# SIGINT whilst child proc is running is not seen by trap so we run a copy here instead of using
|
||||
# trap copy_output SIGINT EXIT
|
||||
copy_output
|
||||
EOF
|
||||
RUN chmod +x /tox-wrapper.sh
|
||||
|
||||
VOLUME /src
|
||||
|
||||
ENTRYPOINT ["/tox-wrapper.sh"]
|
||||
@@ -6,6 +6,7 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--datadir", required=True)
|
||||
parser.add_argument("--sourcedir", required=True)
|
||||
parser.add_argument("--test-argument", required=True)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
shutil.copytree(args.sourcedir, args.datadir)
|
||||
|
||||
@@ -65,20 +65,21 @@ Feature: basic replication
|
||||
Then I receive a response returncode 0
|
||||
And postgres2 role is the primary after 24 seconds
|
||||
And Response on GET http://127.0.0.1:8010/history contains recovery after 10 seconds
|
||||
And there is a postgres2_cb.log with "on_role_change master batman" in postgres2 data directory
|
||||
When I issue a PATCH request to http://127.0.0.1:8010/config with {"synchronous_mode": null, "master_start_timeout": 0}
|
||||
Then I receive a response code 200
|
||||
When I add the table bar to postgres2
|
||||
Then table bar is present on postgres1 after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8010/config contains master_start_timeout after 10 seconds
|
||||
|
||||
Scenario: check immediate failover when master_start_timeout=0
|
||||
Given I kill postmaster on postgres2
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check rejoin of the former primary with pg_rewind
|
||||
Given I add the table splitbrain to postgres0
|
||||
And I start postgres0
|
||||
Then postgres0 role is the secondary after 20 seconds
|
||||
When I add the table buz to postgres1
|
||||
When I add the table buz to postgres2
|
||||
Then table buz is present on postgres0 after 20 seconds
|
||||
|
||||
@reject-duplicate-name
|
||||
Scenario: check graceful rejection when two nodes have the same name
|
||||
Given I start duplicate postgres0 on port 8011
|
||||
Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds
|
||||
|
||||
+12
-11
@@ -10,20 +10,21 @@ Feature: citus
|
||||
And I start postgres3 in citus group 1
|
||||
Then replication works from postgres0 to postgres1 after 15 seconds
|
||||
Then replication works from postgres2 to postgres3 after 15 seconds
|
||||
And postgres0 is registered in the coordinator postgres0 as the worker in group 0
|
||||
And postgres2 is registered in the coordinator postgres0 as the worker in group 1
|
||||
And postgres0 is registered in the postgres0 as the primary in group 0 after 5 seconds
|
||||
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
|
||||
|
||||
Scenario: coordinator failover updates pg_dist_node
|
||||
Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force
|
||||
Then postgres1 role is the primary after 10 seconds
|
||||
And "members/postgres0" key in a group 0 in DCS has state=running after 15 seconds
|
||||
And replication works from postgres1 to postgres0 after 15 seconds
|
||||
And postgres1 is registered in the postgres2 as the primary in group 0 after 5 seconds
|
||||
And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds
|
||||
And postgres1 is registered in the coordinator postgres1 as the worker in group 0
|
||||
When I run patronictl.py failover batman --group 0 --candidate postgres0 --force
|
||||
When I run patronictl.py switchover batman --group 0 --candidate postgres0 --force
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
And replication works from postgres0 to postgres1 after 15 seconds
|
||||
And postgres0 is registered in the postgres2 as the primary in group 0 after 5 seconds
|
||||
And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds
|
||||
And postgres0 is registered in the coordinator postgres0 as the worker in group 0
|
||||
|
||||
Scenario: worker switchover doesn't break client queries on the coordinator
|
||||
Given I create a distributed table on postgres0
|
||||
@@ -31,16 +32,17 @@ Feature: citus
|
||||
When I run patronictl.py switchover batman --group 1 --force
|
||||
Then I receive a response returncode 0
|
||||
And postgres3 role is the primary after 10 seconds
|
||||
And "members/postgres2" key in a group 1 in DCS has state=running after 15 seconds
|
||||
And replication works from postgres3 to postgres2 after 15 seconds
|
||||
And postgres3 is registered in the postgres0 as the primary in group 1 after 5 seconds
|
||||
And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds
|
||||
And postgres3 is registered in the coordinator postgres0 as the worker in group 1
|
||||
And a thread is still alive
|
||||
When I run patronictl.py switchover batman --group 1 --force
|
||||
Then I receive a response returncode 0
|
||||
And postgres2 role is the primary after 10 seconds
|
||||
And replication works from postgres2 to postgres3 after 15 seconds
|
||||
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
|
||||
And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds
|
||||
And postgres2 is registered in the coordinator postgres0 as the worker in group 1
|
||||
And a thread is still alive
|
||||
When I stop a thread
|
||||
Then a distributed table on postgres0 has expected rows
|
||||
@@ -52,7 +54,7 @@ Feature: citus
|
||||
Then I receive a response returncode 0
|
||||
And postgres2 role is the primary after 10 seconds
|
||||
And replication works from postgres2 to postgres3 after 15 seconds
|
||||
And postgres2 is registered in the coordinator postgres0 as the worker in group 1
|
||||
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
|
||||
And a thread is still alive
|
||||
When I stop a thread
|
||||
Then a distributed table on postgres0 has expected rows
|
||||
@@ -64,9 +66,8 @@ Feature: citus
|
||||
When I run patronictl.py edit-config batman --group 2 -s ttl=20 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "+ttl: 20"
|
||||
When I sleep for 2 seconds
|
||||
Then postgres4 is registered in the coordinator postgres0 as the worker in group 2
|
||||
Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds
|
||||
When I shut down postgres4
|
||||
Then There is a transaction in progress on postgres0 changing pg_dist_node
|
||||
Then there is a transaction in progress on postgres0 changing pg_dist_node after 5 seconds
|
||||
When I run patronictl.py restart batman postgres2 --group 1 --force
|
||||
Then a transaction finishes in 20 seconds
|
||||
|
||||
@@ -4,14 +4,14 @@ Feature: dcs failsafe mode
|
||||
Scenario: check failsafe mode can be successfully enabled
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
And I sleep for 3 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 5, "failsafe_mode": true}
|
||||
Then "config" key in DCS has ttl=30 after 10 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 3, "failsafe_mode": true}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres0 after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/failsafe
|
||||
Then I receive a response code 200
|
||||
And I receive a response postgres0 http://127.0.0.1:8008/patroni
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}},"slots":{"dcs_slot_1": null,"postgres0":null}}
|
||||
Then I receive a response code 200
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots": {"dcs_slot_0": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
|
||||
Then I receive a response code 200
|
||||
@@ -28,7 +28,6 @@ Feature: dcs failsafe mode
|
||||
When I do a backup of postgres0
|
||||
And I shut down postgres0
|
||||
When I start postgres1 in a cluster batman from backup with no_leader
|
||||
And I sleep for 2 seconds
|
||||
Then postgres1 role is the replica after 12 seconds
|
||||
|
||||
Scenario: check leader and replica are both in /failsafe key after leader is back
|
||||
@@ -45,41 +44,73 @@ Feature: dcs failsafe mode
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: check leader and replica are functioning while DCS is down
|
||||
Given logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
Given I get all changes from physical slot dcs_slot_1 on postgres0
|
||||
Then physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And DCS is down
|
||||
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
And postgres1 role is the replica after 2 seconds
|
||||
And replication works from postgres0 to postgres1 after 10 seconds
|
||||
And I get all changes from logical slot dcs_slot_0 on postgres0
|
||||
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
|
||||
When I get all changes from logical slot dcs_slot_0 on postgres0
|
||||
And I get all changes from physical slot dcs_slot_1 on postgres0
|
||||
Then logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check primary is demoted when one replica is shut down and DCS is down
|
||||
Given DCS is down
|
||||
And I kill postgres1
|
||||
And I kill postmaster on postgres1
|
||||
And I sleep for 2 seconds
|
||||
Then postgres0 role is the replica after 12 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check known replica is promoted when leader is down and DCS is up
|
||||
Given I shut down postgres0
|
||||
Given I kill postgres0
|
||||
And I shut down postmaster on postgres0
|
||||
And DCS is up
|
||||
When I start postgres1
|
||||
Then "members/postgres1" key in DCS has state=running after 10 seconds
|
||||
And postgres1 role is the primary after 25 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check three-node cluster is functioning while DCS is down
|
||||
Scenario: scale to three-node cluster
|
||||
Given I start postgres0
|
||||
And I start postgres2
|
||||
Then "members/postgres2" key in DCS has state=running after 10 seconds
|
||||
And "members/postgres0" key in DCS has state=running after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres2 after 10 seconds
|
||||
And replication works from postgres1 to postgres0 after 10 seconds
|
||||
And replication works from postgres1 to postgres2 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: make sure permanent slots exist on replicas
|
||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
|
||||
When I get all changes from physical slot dcs_slot_1 on postgres1
|
||||
Then physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check three-node cluster is functioning while DCS is down
|
||||
Given DCS is down
|
||||
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
|
||||
Then Response on GET http://127.0.0.1:8009/primary contains failsafe_mode_is_active after 12 seconds
|
||||
Then postgres1 role is the primary after 10 seconds
|
||||
And postgres0 role is the replica after 2 seconds
|
||||
And postgres2 role is the replica after 2 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: check that permanent slots are in sync between nodes while DCS is down
|
||||
Given replication works from postgres1 to postgres0 after 10 seconds
|
||||
And replication works from postgres1 to postgres2 after 10 seconds
|
||||
When I get all changes from logical slot dcs_slot_2 on postgres1
|
||||
And I get all changes from physical slot dcs_slot_1 on postgres1
|
||||
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
|
||||
+64
-41
@@ -7,7 +7,7 @@ import psutil
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import six
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -17,12 +17,11 @@ import yaml
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from patroni.request import PatroniRequest
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractController(object):
|
||||
class AbstractController(abc.ABC):
|
||||
|
||||
def __init__(self, context, name, work_directory, output_dir):
|
||||
self._context = context
|
||||
@@ -53,15 +52,14 @@ class AbstractController(object):
|
||||
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
|
||||
self._handle = self._start()
|
||||
|
||||
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
|
||||
|
||||
max_wait_limit *= self._context.timeout_multiplier
|
||||
for _ in range(max_wait_limit):
|
||||
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
|
||||
if self._is_accessible():
|
||||
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):
|
||||
@@ -164,9 +162,10 @@ class PatroniController(AbstractController):
|
||||
|
||||
def stop(self, kill=False, timeout=15, postgres=False):
|
||||
if postgres:
|
||||
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w'])
|
||||
mode = 'i' if kill else 'f'
|
||||
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-m' + mode, '-w'])
|
||||
super(PatroniController, self).stop(kill, timeout)
|
||||
if isinstance(self._context.dcs_ctl, KubernetesController):
|
||||
if isinstance(self._context.dcs_ctl, KubernetesController) and not kill:
|
||||
self._context.dcs_ctl.delete_pod(self._name[8:])
|
||||
if self.watchdog:
|
||||
self.watchdog.stop()
|
||||
@@ -193,7 +192,7 @@ class PatroniController(AbstractController):
|
||||
config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']}
|
||||
|
||||
host = config['restapi']['listen'].rsplit(':', 1)[0]
|
||||
config['restapi']['listen'] = config['restapi']['connect_address'] = '{0}:{1}'.format(host, 8008+int(name[-1]))
|
||||
config['restapi']['listen'] = config['restapi']['connect_address'] = '{}:{}'.format(host, 8008 + int(name[-1]))
|
||||
|
||||
host = config['postgresql']['listen'].rsplit(':', 1)[0]
|
||||
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
|
||||
@@ -201,6 +200,8 @@ class PatroniController(AbstractController):
|
||||
config['name'] = name
|
||||
config['postgresql']['data_dir'] = self._data_dir.replace('\\', '/')
|
||||
config['postgresql']['basebackup'] = [{'checkpoint': 'fast'}]
|
||||
config['postgresql']['callbacks'] = {
|
||||
'on_role_change': '{0} features/callback2.py {1}'.format(self._context.pctl.PYTHON, name)}
|
||||
config['postgresql']['use_unix_socket'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
|
||||
config['postgresql']['use_unix_socket_repl'] = os.name != 'nt'
|
||||
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name).replace('\\', '/')
|
||||
@@ -244,6 +245,10 @@ class PatroniController(AbstractController):
|
||||
self.recursive_update(config, custom_config)
|
||||
|
||||
self.recursive_update(config, {
|
||||
'log': {
|
||||
'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)d - %(funcName)s]: %(message)s',
|
||||
'loggers': {'patroni.postgresql.callback_executor': 'DEBUG'}
|
||||
},
|
||||
'bootstrap': {
|
||||
'dcs': {
|
||||
'loop_wait': 2,
|
||||
@@ -251,9 +256,9 @@ class PatroniController(AbstractController):
|
||||
'parameters': {
|
||||
'wal_keep_segments': 100,
|
||||
'archive_mode': 'on',
|
||||
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT +
|
||||
' --mode archive ' +
|
||||
'--dirname {} --filename %f --pathname %p').format(
|
||||
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||
+ ' --mode archive '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self._work_directory, 'data', 'wal_archive'))
|
||||
}
|
||||
}
|
||||
@@ -343,6 +348,13 @@ class PatroniController(AbstractController):
|
||||
'--datadir=' + os.path.join(self._work_directory, dest),
|
||||
'--dbname=' + self.backup_source])
|
||||
|
||||
def read_patroni_log(self, level):
|
||||
try:
|
||||
with open(str(os.path.join(self._output_dir or '', self._name + ".log"))) as f:
|
||||
return [line for line in f.readlines() if line[24:24 + len(level)] == level]
|
||||
except IOError:
|
||||
return []
|
||||
|
||||
|
||||
class ProcessHang(object):
|
||||
|
||||
@@ -594,7 +606,7 @@ class KubernetesController(AbstractExternalDcsController):
|
||||
api_process = 'kube-apiserver'
|
||||
elif context.startswith('k3d-'):
|
||||
container = '{0}-server-0'.format(context)
|
||||
api_process = 'k3s'
|
||||
api_process = 'k3s server'
|
||||
else:
|
||||
return super(KubernetesController, self)._is_running()
|
||||
try:
|
||||
@@ -642,9 +654,10 @@ class KubernetesController(AbstractExternalDcsController):
|
||||
try:
|
||||
if group is not None:
|
||||
scope = '{0}-{1}'.format(scope, group)
|
||||
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(key, '-' + key)
|
||||
rkey = 'leader' if key in ('status', 'failsafe') else key
|
||||
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(rkey, '-' + rkey)
|
||||
e = self._api.read_namespaced_endpoints(ep, self._namespace)
|
||||
if key != 'sync':
|
||||
if key not in ('sync', 'status', 'failsafe'):
|
||||
return e.metadata.annotations[key]
|
||||
else:
|
||||
return json.dumps(e.metadata.annotations)
|
||||
@@ -680,7 +693,7 @@ class ZooKeeperController(AbstractExternalDcsController):
|
||||
self._client = kazoo.client.KazooClient()
|
||||
|
||||
def process_name(self):
|
||||
return "zookeeper"
|
||||
return "java .*zookeeper"
|
||||
|
||||
def query(self, key, scope='batman', group=None):
|
||||
import kazoo.exceptions
|
||||
@@ -801,7 +814,7 @@ class PatroniPoolController(object):
|
||||
raise Exception # this one should never happen because the previous line will always raise and exception
|
||||
except Exception as e:
|
||||
self._context.postgres_supports_ssl = isinstance(e, subprocess.CalledProcessError)\
|
||||
and 'SSL is not supported by this build' not in e.output.decode()
|
||||
and 'SSL is not supported by this build' not in e.output.decode()
|
||||
|
||||
@property
|
||||
def patroni_path(self):
|
||||
@@ -826,7 +839,7 @@ class PatroniPoolController(object):
|
||||
|
||||
def __getattr__(self, func):
|
||||
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to',
|
||||
'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup']:
|
||||
'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup', 'read_patroni_log']:
|
||||
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
|
||||
|
||||
def wrapper(name, *args, **kwargs):
|
||||
@@ -853,8 +866,8 @@ class PatroniPoolController(object):
|
||||
'bootstrap': {
|
||||
'method': 'pg_basebackup',
|
||||
'pg_basebackup': {
|
||||
'command': " ".join(self.BACKUP_SCRIPT +
|
||||
['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)])
|
||||
'command': " ".join(self.BACKUP_SCRIPT
|
||||
+ ['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)])
|
||||
},
|
||||
'dcs': {
|
||||
'postgresql': {
|
||||
@@ -867,9 +880,9 @@ class PatroniPoolController(object):
|
||||
'postgresql': {
|
||||
'parameters': {
|
||||
'archive_mode': 'on',
|
||||
'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive ' +
|
||||
'--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
|
||||
'archive_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode archive '
|
||||
+ '--dirname {} --filename %f --pathname %p')
|
||||
.format(os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
|
||||
},
|
||||
'authentication': {
|
||||
'superuser': {'password': 'zalando1'},
|
||||
@@ -879,22 +892,28 @@ class PatroniPoolController(object):
|
||||
}
|
||||
self.start(to_name, custom_config=custom_config)
|
||||
|
||||
def backup_restore_config(self, params=None):
|
||||
return {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT
|
||||
+ ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')).replace('\\', '/'),
|
||||
'test-argument': 'test-value', # test config mapping approach on custom bootstrap/replica creation
|
||||
**(params or {}),
|
||||
}
|
||||
|
||||
def bootstrap_from_backup(self, name, cluster_name):
|
||||
custom_config = {
|
||||
'scope': cluster_name,
|
||||
'bootstrap': {
|
||||
'method': 'backup_restore',
|
||||
'backup_restore': {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' +
|
||||
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
|
||||
'backup_restore': self.backup_restore_config({
|
||||
'recovery_conf': {
|
||||
'recovery_target_action': 'promote',
|
||||
'recovery_target_timeline': 'latest',
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' +
|
||||
'--dirname {} --filename %f --pathname %p').format(
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
'postgresql': {
|
||||
'authentication': {
|
||||
@@ -910,16 +929,12 @@ class PatroniPoolController(object):
|
||||
'scope': cluster_name,
|
||||
'postgresql': {
|
||||
'recovery_conf': {
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' +
|
||||
'--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
|
||||
+ '--dirname {} --filename %f --pathname %p')
|
||||
.format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||
},
|
||||
'create_replica_methods': ['no_leader_bootstrap'],
|
||||
'no_leader_bootstrap': {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' +
|
||||
os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
|
||||
'no_leader': '1'
|
||||
}
|
||||
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
|
||||
}
|
||||
}
|
||||
self.start(name, custom_config=custom_config)
|
||||
@@ -1058,11 +1073,15 @@ def before_all(context):
|
||||
context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key')
|
||||
context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt')
|
||||
try:
|
||||
if sys.platform == 'darwin' and 'GITHUB_ACTIONS' in os.environ:
|
||||
raise Exception
|
||||
with open(os.devnull, 'w') as null:
|
||||
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
|
||||
'-keyout', context.keyfile, '-out', context.certfile], stdout=null, stderr=null)
|
||||
'-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', context.keyfile,
|
||||
'-out', context.certfile], stdout=null, stderr=null)
|
||||
if ret != 0:
|
||||
raise Exception
|
||||
os.chmod(context.keyfile, stat.S_IWRITE | stat.S_IREAD)
|
||||
except Exception:
|
||||
context.keyfile = context.certfile = None
|
||||
|
||||
@@ -1073,7 +1092,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)
|
||||
@@ -1135,3 +1156,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')
|
||||
|
||||
@@ -10,7 +10,7 @@ Feature: ignored slots
|
||||
When I shut down postgres1
|
||||
And I start postgres1
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And "members/postgres1" key in DCS has role=master after 3 seconds
|
||||
And "members/postgres1" key in DCS has role=master after 10 seconds
|
||||
# Make sure Patroni has finished telling Postgres it should be accepting writes.
|
||||
And postgres1 role is the primary after 20 seconds
|
||||
# 1. Create our test logical replication slot.
|
||||
@@ -25,37 +25,37 @@ Feature: ignored slots
|
||||
# but Patroni can actually end up dropping them almost immediately, so it's helpful
|
||||
# to verify they exist before we begin testing whether they persist through failover
|
||||
# cycles.
|
||||
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
|
||||
When I start postgres0
|
||||
Then "members/postgres0" key in DCS has role=replica after 3 seconds
|
||||
Then "members/postgres0" key in DCS has role=replica after 10 seconds
|
||||
And postgres0 role is the secondary after 20 seconds
|
||||
# Verify that the replica has advanced beyond the point in the WAL
|
||||
# where we created the replication slot so that on the next failover
|
||||
# cycle we don't accidentally rewind to before the slot creation.
|
||||
And replication works from postgres1 to postgres0 after 20 seconds
|
||||
When I shut down postgres1
|
||||
Then "members/postgres0" key in DCS has role=master after 3 seconds
|
||||
Then "members/postgres0" key in DCS has role=master after 10 seconds
|
||||
|
||||
# 2. After a failover the server (now a replica) still has the slot.
|
||||
When I start postgres1
|
||||
Then postgres1 role is the secondary after 20 seconds
|
||||
And "members/postgres1" key in DCS has role=replica after 3 seconds
|
||||
And "members/postgres1" key in DCS has role=replica after 10 seconds
|
||||
# give Patroni time to sync replication slots
|
||||
And I sleep for 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
And postgres1 does not have a logical replication slot named dummy_slot
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 does not have a replication slot named dummy_slot
|
||||
|
||||
# 3. After a failover the server (now a primary) still has the slot.
|
||||
When I shut down postgres0
|
||||
Then "members/postgres1" key in DCS has role=master after 3 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
Then "members/postgres1" key in DCS has role=master after 10 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
|
||||
@@ -35,30 +35,30 @@ Scenario: check local configuration reload
|
||||
Then I receive a response code 202
|
||||
|
||||
Scenario: check dynamic configuration change via DCS
|
||||
Given I run patronictl.py edit-config -s 'ttl=10' -p 'max_connections=101' --force batman
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "+ttl: 10"
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "postgresql": {"parameters": {"max_connections": "101"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/config
|
||||
Then I receive a response code 200
|
||||
And I receive a response ttl 10
|
||||
And I receive a response ttl 20
|
||||
When I issue a GET request to http://127.0.0.1:8008/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response tags {'new_tag': 'new_value'}
|
||||
And I sleep for 4 seconds
|
||||
|
||||
Scenario: check the scheduled restart
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
|
||||
Given I run patronictl.py edit-config -p 'superuser_reserved_connections=6' --force batman
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "+ superuser_reserved_connections: 6"
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"}
|
||||
Then I receive a response code 202
|
||||
And I sleep for 8 seconds
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
|
||||
And I sleep for 8 seconds
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"}
|
||||
Then I receive a response code 202
|
||||
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Given I start postgres1
|
||||
@@ -68,6 +68,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
When I kill postmaster on postgres1
|
||||
And I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 503
|
||||
And "members/postgres1" key in DCS has state=stopped after 10 seconds
|
||||
When I run patronictl.py restart batman postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
@@ -76,15 +77,15 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
And I receive a response role replica
|
||||
When I run patronictl.py reinit batman postgres1 --force
|
||||
When I run patronictl.py reinit batman postgres1 --force --wait
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Success: reinitialize for member postgres1"
|
||||
And postgres1 role is the secondary after 30 seconds
|
||||
And replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I run patronictl.py restart batman postgres0 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Success: restart on member postgres0"
|
||||
And postgres0 role is the primary after 5 seconds
|
||||
When I sleep for 10 seconds
|
||||
Then postgres1 role is the secondary after 15 seconds
|
||||
|
||||
Scenario: check the switchover via the API in the pause mode
|
||||
Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0", "candidate": "postgres1"}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
Feature: permanent slots
|
||||
Scenario: check that physical permanent slots are created
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_physical":0,"postgres0":0,"postgres1":0,"postgres3":0},"postgresql":{"parameters":{"wal_level":"logical"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/config contains slots after 10 seconds
|
||||
When I start postgres1
|
||||
And I start postgres2
|
||||
And I configure and start postgres3 with a tag replicatefrom postgres2
|
||||
Then postgres0 has a physical replication slot named test_physical after 10 seconds
|
||||
And postgres0 has a physical replication slot named postgres1 after 10 seconds
|
||||
And postgres0 has a physical replication slot named postgres2 after 10 seconds
|
||||
And postgres2 has a physical replication slot named postgres3 after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that logical permanent slots are created
|
||||
Given I run patronictl.py restart batman postgres0 --force
|
||||
And I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that permanent slots are created on replicas
|
||||
Given postgres1 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
|
||||
And postgres1 has a physical replication slot named test_physical after 2 seconds
|
||||
And postgres2 has a physical replication slot named test_physical after 2 seconds
|
||||
And postgres3 has a physical replication slot named test_physical after 2 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check permanent physical slots that match with member names
|
||||
Given postgres0 has a physical replication slot named postgres3 after 2 seconds
|
||||
And postgres1 has a physical replication slot named postgres0 after 2 seconds
|
||||
And postgres1 has a physical replication slot named postgres3 after 2 seconds
|
||||
And postgres2 has a physical replication slot named postgres0 after 2 seconds
|
||||
And postgres2 has a physical replication slot named postgres3 after 2 seconds
|
||||
And postgres2 has a physical replication slot named postgres1 after 2 seconds
|
||||
And postgres1 does not have a replication slot named postgres2
|
||||
And postgres3 does not have a replication slot named postgres2
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that permanent slots are advanced on replicas
|
||||
Given I add the table replicate_me to postgres0
|
||||
When I get all changes from logical slot test_logical on postgres0
|
||||
And I get all changes from physical slot test_physical on postgres0
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres0 and postgres3 after 10 seconds
|
||||
And Physical slot postgres1 is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Physical slot postgres3 is in sync between postgres2 and postgres0 after 20 seconds
|
||||
And Physical slot postgres3 is in sync between postgres2 and postgres1 after 10 seconds
|
||||
And postgres1 does not have a replication slot named postgres2
|
||||
And postgres3 does not have a replication slot named postgres2
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that only permanent slots are written to the /status key
|
||||
Given "status" key in DCS has test_physical in slots
|
||||
And "status" key in DCS has postgres0 in slots
|
||||
And "status" key in DCS has postgres1 in slots
|
||||
And "status" key in DCS does not have postgres2 in slots
|
||||
And "status" key in DCS has postgres3 in slots
|
||||
|
||||
Scenario: check permanent physical replication slot after failover
|
||||
Given I shut down postgres3
|
||||
And I shut down postgres2
|
||||
And I shut down postgres0
|
||||
Then postgres1 has a physical replication slot named test_physical after 10 seconds
|
||||
And postgres1 has a physical replication slot named postgres0 after 10 seconds
|
||||
And postgres1 has a physical replication slot named postgres3 after 10 seconds
|
||||
@@ -0,0 +1,39 @@
|
||||
Feature: priority replication
|
||||
We should check that we can give nodes priority during failover
|
||||
|
||||
Scenario: check failover priority 0 prevents leaderships
|
||||
Given I configure and start postgres0 with a tag failover_priority 1
|
||||
And I configure and start postgres1 with a tag failover_priority 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I shut down postgres0
|
||||
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
|
||||
Then postgres1 role is the secondary after 10 seconds
|
||||
When I start postgres0
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check higher failover priority is respected
|
||||
Given I configure and start postgres2 with a tag failover_priority 1
|
||||
And I configure and start postgres3 with a tag failover_priority 2
|
||||
Then replication works from postgres0 to postgres2 after 20 seconds
|
||||
And replication works from postgres0 to postgres3 after 20 seconds
|
||||
When I shut down postgres0
|
||||
Then postgres3 role is the primary after 10 seconds
|
||||
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
|
||||
|
||||
Scenario: check conflicting configuration handling
|
||||
When I set nofailover tag in postgres2 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8010/reload
|
||||
Then I receive a response code 202
|
||||
And there is one of ["Conflicting configuration between nofailover: True and failover_priority: 1. Defaulting to nofailover: True"] WARNING in the postgres2 patroni log after 5 seconds
|
||||
And "members/postgres2" key in DCS has tags={'failover_priority': '1', 'nofailover': True} after 10 seconds
|
||||
When I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres2"}
|
||||
Then I receive a response code 412
|
||||
And I receive a response text "failover is not possible: no good candidates have been found"
|
||||
When I reset nofailover tag in postgres1 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8009/reload
|
||||
Then I receive a response code 202
|
||||
And there is one of ["Conflicting configuration between nofailover: False and failover_priority: 0. Defaulting to nofailover: False"] WARNING in the postgres1 patroni log after 5 seconds
|
||||
And "members/postgres1" key in DCS has tags={'failover_priority': '0', 'nofailover': False} after 10 seconds
|
||||
And I issue a POST request to http://127.0.0.1:8009/failover with {"candidate": "postgres1"}
|
||||
Then I receive a response code 200
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
@@ -0,0 +1,26 @@
|
||||
Feature: recovery
|
||||
We want to check that crashed postgres is started back
|
||||
|
||||
Scenario: check that timeline is not incremented when primary is started after crash
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I start postgres1
|
||||
And I add the table foo to postgres0
|
||||
Then table foo is present on postgres1 after 20 seconds
|
||||
When I kill postmaster on postgres0
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/
|
||||
Then I receive a response code 200
|
||||
And I receive a response role master
|
||||
And I receive a response timeline 1
|
||||
And "members/postgres0" key in DCS has state=running after 12 seconds
|
||||
And replication works from postgres0 to postgres1 after 15 seconds
|
||||
|
||||
Scenario: check immediate failover when master_start_timeout=0
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"master_start_timeout": 0}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/config contains master_start_timeout after 10 seconds
|
||||
When I kill postmaster on postgres0
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
@@ -13,14 +13,15 @@ Feature: standby cluster
|
||||
When I start postgres0
|
||||
Then "members/postgres0" key in DCS has state=running after 10 seconds
|
||||
And replication works from postgres1 to postgres0 after 15 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response replication_state streaming
|
||||
And "members/postgres0" key in DCS has replication_state=streaming after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check permanent logical slots are synced to the replica
|
||||
Given I run patronictl.py restart batman postgres1 --force
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
When I add the table replicate_me to postgres1
|
||||
And I get all changes from logical slot test_logical on postgres1
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
Scenario: Detach exiting node from the cluster
|
||||
When I shut down postgres1
|
||||
@@ -34,6 +35,9 @@ Feature: standby cluster
|
||||
Then postgres1 is a leader of batman1 after 10 seconds
|
||||
When I add the table foo to postgres0
|
||||
Then table foo is present on postgres1 after 20 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response replication_state streaming
|
||||
And I sleep for 3 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/primary
|
||||
Then I receive a response code 503
|
||||
@@ -44,17 +48,24 @@ Feature: standby cluster
|
||||
When I start postgres2 in a cluster batman1
|
||||
Then postgres2 role is the replica after 24 seconds
|
||||
And table foo is present on postgres2 after 20 seconds
|
||||
And postgres1 does not have a logical replication slot named test_logical
|
||||
When I issue a GET request to http://127.0.0.1:8010/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response replication_state streaming
|
||||
And postgres1 does not have a replication slot named test_logical
|
||||
|
||||
Scenario: check switchover
|
||||
Given I run patronictl.py switchover batman1 --force
|
||||
Then Status code on GET http://127.0.0.1:8010/standby_leader is 200 after 10 seconds
|
||||
And postgres1 is replicating from postgres2 after 32 seconds
|
||||
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
|
||||
|
||||
Scenario: check failover
|
||||
When I kill postgres1
|
||||
And I kill postmaster on postgres1
|
||||
Then postgres2 is replicating from postgres0 after 32 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8010/primary
|
||||
When I kill postgres2
|
||||
And I kill postmaster on postgres2
|
||||
Then postgres1 is replicating from postgres0 after 32 seconds
|
||||
And Status code on GET http://127.0.0.1:8009/standby_leader is 200 after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/primary
|
||||
Then I receive a response code 503
|
||||
And I sleep for 3 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8010/standby_leader
|
||||
Then I receive a response code 200
|
||||
And I receive a response role standby_leader
|
||||
And replication works from postgres0 to postgres2 after 15 seconds
|
||||
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
|
||||
And replication works from postgres0 to postgres1 after 15 seconds
|
||||
And there is a postgres1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres1 data directory
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import patroni.psycopg as pg
|
||||
|
||||
from behave import step, then
|
||||
@@ -9,6 +10,22 @@ def start_patroni(context, name):
|
||||
return context.pctl.start(name)
|
||||
|
||||
|
||||
@step('I start duplicate {name:w} on port {port:d}')
|
||||
def start_duplicate_patroni(context, name, port):
|
||||
config = {
|
||||
"name": name,
|
||||
"restapi": {
|
||||
"listen": "127.0.0.1:{0}".format(port)
|
||||
}
|
||||
}
|
||||
try:
|
||||
context.pctl.start('dup-' + name, custom_config=config)
|
||||
assert False, "Process was expected to fail"
|
||||
except AssertionError as e:
|
||||
assert 'is not running after being started' in str(e), \
|
||||
"No error was raised by duplicate start of {0} ".format(name)
|
||||
|
||||
|
||||
@step('I shut down {name:w}')
|
||||
def stop_patroni(context, name):
|
||||
return context.pctl.stop(name, timeout=60)
|
||||
@@ -19,11 +36,16 @@ def kill_patroni(context, name):
|
||||
return context.pctl.stop(name, kill=True)
|
||||
|
||||
|
||||
@step('I kill postmaster on {name:w}')
|
||||
@step('I shut down postmaster on {name:w}')
|
||||
def stop_postgres(context, name):
|
||||
return context.pctl.stop(name, postgres=True)
|
||||
|
||||
|
||||
@step('I kill postmaster on {name:w}')
|
||||
def kill_postgres(context, name):
|
||||
return context.pctl.stop(name, kill=True, postgres=True)
|
||||
|
||||
|
||||
@step('I add the table {table_name:w} to {pg_name:w}')
|
||||
def add_table(context, table_name, pg_name):
|
||||
# parse the configuration file and get the port
|
||||
@@ -38,7 +60,7 @@ def toggle_wal_replay(context, action, pg_name):
|
||||
# pause or resume the wal replay process
|
||||
try:
|
||||
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
|
||||
wal_name = 'xlog' if int(version)/10000 < 10 else 'wal'
|
||||
wal_name = 'xlog' if int(version) / 10000 < 10 else 'wal'
|
||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
|
||||
except pg.Error as e:
|
||||
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
|
||||
@@ -72,14 +94,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)
|
||||
|
||||
|
||||
@@ -89,4 +111,18 @@ def replication_works(context, primary, replica, time_limit):
|
||||
context.execute_steps(u"""
|
||||
When I add the table test_{0} to {1}
|
||||
Then table test_{0} is present on {2} after {3} seconds
|
||||
""".format(int(time()), primary, replica, time_limit))
|
||||
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
|
||||
|
||||
|
||||
@step('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
|
||||
def check_patroni_log(context, message_list, level, node, timeout):
|
||||
timeout *= context.timeout_multiplier
|
||||
message_list = json.loads(message_list)
|
||||
|
||||
for _ in range(int(timeout)):
|
||||
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
||||
if any(any(message in line for line in messsages_of_level) for message in message_list):
|
||||
break
|
||||
sleep(1)
|
||||
else:
|
||||
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
|
||||
|
||||
@@ -11,11 +11,8 @@ def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
|
||||
|
||||
@then('There is a {label} with "{content}" in {name:w} data directory')
|
||||
def check_label(context, label, content, name):
|
||||
label = context.pctl.read_label(name, label)
|
||||
if label is None:
|
||||
label = ""
|
||||
label = label.replace('\n', '\\n')
|
||||
assert content in label, "\"{0}\" doesn't contain {1}".format(label, content)
|
||||
value = (context.pctl.read_label(name, label) or '').replace('\n', '\\n')
|
||||
assert content in value, "\"{0}\" in {1} doesn't contain {2}".format(value, label, content)
|
||||
|
||||
|
||||
@step('I create label with "{content:w}" in {name:w} data directory')
|
||||
@@ -31,7 +28,7 @@ def check_member(context, name, key, value, time_limit):
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
dcs_value = response.get(key)
|
||||
dcs_value = str(response.get(key))
|
||||
if dcs_value == value:
|
||||
return
|
||||
except Exception:
|
||||
|
||||
+33
-15
@@ -35,7 +35,7 @@ def check_group_member(context, name, group, key, value, time_limit):
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, ("{0} in a group {1} does not have {2}={3} (found {4}) in dcs" +
|
||||
assert False, ("{0} in a group {1} does not have {2}={3} (found {4}) in dcs"
|
||||
" after {5} seconds").format(name, group, key, value, response, time_limit)
|
||||
|
||||
|
||||
@@ -44,12 +44,24 @@ def start_citus(context, name, group):
|
||||
return context.pctl.start(name, custom_config={"citus": {"database": "postgres", "group": int(group)}})
|
||||
|
||||
|
||||
@step('{name1:w} is registered in the coordinator {name2:w} as the worker in group {group:d}')
|
||||
def check_registration(context, name1, name2, group):
|
||||
@step('{name1:w} is registered in the {name2:w} as the {role:w} in group {group:d} after {time_limit:d} seconds')
|
||||
def check_registration(context, name1, name2, role, group, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
|
||||
worker_port = int(context.pctl.query(name1, "SHOW port").fetchone()[0])
|
||||
r = context.pctl.query(name2, "SELECT nodeport FROM pg_catalog.pg_dist_node WHERE groupid = {0}".format(group))
|
||||
assert worker_port == r.fetchone()[0],\
|
||||
"Worker {0} is not registered in pg_dist_node on the coordinator {1}".format(name1, name2)
|
||||
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
cur = context.pctl.query(name2, "SELECT nodeport, noderole"
|
||||
" FROM pg_catalog.pg_dist_node WHERE groupid = {0}".format(group))
|
||||
mapping = {r[0]: r[1] for r in cur}
|
||||
if mapping.get(worker_port) == role:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "Node {0} is not registered in pg_dist_node on the node {1}".format(name1, name2)
|
||||
|
||||
|
||||
@step('I create a distributed table on {name:w}')
|
||||
@@ -93,7 +105,7 @@ def thread_is_alive(context):
|
||||
@step("I stop a thread")
|
||||
def stop_insert_thread(context):
|
||||
context.thread_stop_event.set()
|
||||
context.thread.join(1*context.timeout_multiplier)
|
||||
context.thread.join(1 * context.timeout_multiplier)
|
||||
assert not context.thread.is_alive(), "Thread is still alive"
|
||||
|
||||
|
||||
@@ -103,15 +115,21 @@ def count_rows(context, name):
|
||||
assert rows == context.insert_counter, "Distributed table doesn't have expected amount of rows"
|
||||
|
||||
|
||||
@step("There is a transaction in progress on {name:w} changing pg_dist_node")
|
||||
def check_transaction(context, name):
|
||||
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
|
||||
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
|
||||
assert cur.rowcount == 1, "There is no idle in transaction updating pg_dist_node"
|
||||
context.xact_start = cur.fetchone()[0]
|
||||
@step("there is a transaction in progress on {name:w} changing pg_dist_node after {time_limit:d} seconds")
|
||||
def check_transaction(context, name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
|
||||
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
|
||||
if cur.rowcount == 1:
|
||||
context.xact_start = cur.fetchone()[0]
|
||||
return
|
||||
time.sleep(1)
|
||||
assert False, f"There is no idle in transaction on {name} updating pg_dist_node after {time_limit} seconds"
|
||||
|
||||
|
||||
@step("a transaction finishes in {timeout:d} seconds")
|
||||
def check_transaction_timeout(context, timeout):
|
||||
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout,\
|
||||
"a transaction finished earlier than in {0} seconds".format(timeout)
|
||||
assert (datetime.now(tzutc) - context.xact_start).seconds >= timeout, \
|
||||
"a transaction finished earlier than in {0} seconds".format(timeout)
|
||||
|
||||
@@ -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,
|
||||
@@ -128,6 +128,12 @@ def scheduled_restart(context, url, in_seconds, data):
|
||||
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
|
||||
|
||||
|
||||
@step('I {action:w} {tag:w} tag in {pg_name:w} config')
|
||||
def add_bool_tag_to_config(context, action, tag, pg_name):
|
||||
value = action == 'set'
|
||||
context.pctl.add_tag_to_config(pg_name, tag, value)
|
||||
|
||||
|
||||
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
|
||||
def add_tag_to_config(context, tag, value, pg_name):
|
||||
context.pctl.add_tag_to_config(pg_name, tag, value)
|
||||
@@ -158,7 +164,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)
|
||||
|
||||
|
||||
|
||||
+62
-17
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
@@ -15,21 +16,30 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin):
|
||||
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
|
||||
|
||||
@then('{pg_name:w} has a logical replication slot named {slot_name} with the {plugin:w} plugin')
|
||||
def has_logical_replication_slot(context, pg_name, slot_name, plugin):
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
|
||||
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
|
||||
assert row, "Couldn't find replication slot named {0}".format(slot_name)
|
||||
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name)
|
||||
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
|
||||
"{1} rather than {2}").format(slot_name, row[1], plugin)
|
||||
except pg.Error:
|
||||
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
@step('{pg_name:w} has a logical replication slot named {slot_name}'
|
||||
' with the {plugin:w} plugin after {time_limit:d} seconds')
|
||||
@then('{pg_name:w} has a logical replication slot named {slot_name}'
|
||||
' with the {plugin:w} plugin after {time_limit:d} seconds')
|
||||
def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
|
||||
f" WHERE slot_name = '{slot_name}'")).fetchone()
|
||||
if row:
|
||||
assert row[0] == "logical", f"Replication slot {slot_name} isn't a logical but {row[0]}"
|
||||
assert row[1] == plugin, f"Replication slot {slot_name} using plugin {row[1]} rather than {plugin}"
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, f"Error looking for slot {slot_name} on {pg_name} with plugin {plugin}"
|
||||
|
||||
|
||||
@then('{pg_name:w} does not have a logical replication slot named {slot_name}')
|
||||
def does_not_have_logical_replication_slot(context, pg_name, slot_name):
|
||||
@step('{pg_name:w} does not have a replication slot named {slot_name:w}')
|
||||
@then('{pg_name:w} does not have a replication slot named {slot_name:w}')
|
||||
def does_not_have_replication_slot(context, pg_name, slot_name):
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
|
||||
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
|
||||
@@ -38,13 +48,14 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name):
|
||||
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
|
||||
|
||||
|
||||
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
|
||||
@step('{slot_type:w} slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
column = 'confirmed_flush_lsn' if slot_type.lower() == 'logical' else 'restart_lsn'
|
||||
query = f"SELECT {column} FROM pg_replication_slots WHERE slot_name = '{slot_name}'"
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
|
||||
slot1 = context.pctl.query(pg_name1, query).fetchone()
|
||||
slot2 = context.pctl.query(pg_name2, query).fetchone()
|
||||
if slot1[0] == slot2[0]:
|
||||
@@ -52,9 +63,43 @@ def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
|
||||
assert False, \
|
||||
f"{slot_type} slot {slot_name} is not in sync between {pg_name1} and {pg_name2} after {time_limit} seconds"
|
||||
|
||||
|
||||
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
|
||||
def logical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
|
||||
|
||||
|
||||
@step('I get all changes from physical slot {slot_name:w} on {pg_name:w}')
|
||||
def physical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, f"SELECT * FROM pg_replication_slot_advance('{slot_name}', pg_current_wal_lsn())")
|
||||
|
||||
|
||||
@step('{pg_name:w} has a physical replication slot named {slot_name} after {time_limit:d} seconds')
|
||||
def has_physical_replication_slot(context, pg_name, slot_name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
query = f"SELECT * FROM pg_catalog.pg_replication_slots WHERE slot_type = 'physical' AND slot_name = '{slot_name}'"
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
row = context.pctl.query(pg_name, query).fetchone()
|
||||
if row:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, f"Physical slot {slot_name} doesn't exist after {time_limit} seconds"
|
||||
|
||||
|
||||
@step('"{name}" key in DCS has {subkey:w} in {key:w}')
|
||||
def dcs_key_contains(context, name, subkey, key):
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
assert key in response and subkey in response[key], f"{name} key in DCS doesn't have {subkey} in {key}"
|
||||
|
||||
|
||||
@step('"{name}" key in DCS does not have {subkey:w} in {key:w}')
|
||||
def dcs_key_does_not_contain(context, name, subkey, key):
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
assert key not in response or subkey not in response[key], f"{name} key in DCS has {subkey} in {key}"
|
||||
|
||||
@@ -15,9 +15,7 @@ def start_patroni(context, name, cluster_name):
|
||||
"scope": cluster_name,
|
||||
"postgresql": {
|
||||
"callbacks": callbacks(context, name),
|
||||
"backup_restore": {
|
||||
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir=" +
|
||||
os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
|
||||
"backup_restore": context.pctl.backup_restore_config()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,6 +32,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
"ttl": 20,
|
||||
"loop_wait": 2,
|
||||
"retry_timeout": 5,
|
||||
"synchronous_mode": True, # should be completely ignored
|
||||
"standby_cluster": {
|
||||
"host": "localhost",
|
||||
"port": port,
|
||||
|
||||
@@ -9,8 +9,8 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
| xargs apt-get install -y vim-tiny curl jq locales git python3-pip python3-wheel \
|
||||
## Make sure we have a en_US.UTF-8 locale available
|
||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||
&& pip3 install setuptools \
|
||||
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& pip3 install --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& PGHOME=/home/postgres \
|
||||
&& mkdir -p $PGHOME \
|
||||
&& chown postgres $PGHOME \
|
||||
|
||||
@@ -7,13 +7,27 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \
|
||||
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
|
||||
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel \
|
||||
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \
|
||||
## Make sure we have a en_US.UTF-8 locale available
|
||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||
&& curl https://install.citusdata.com/community/deb.sh | bash \
|
||||
&& apt-get -y install postgresql-15-citus-11.1 \
|
||||
&& pip3 install setuptools \
|
||||
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
apt-get install -y postgresql-server-dev-15 \
|
||||
gcc make autoconf \
|
||||
libc6-dev flex libcurl4-gnutls-dev \
|
||||
libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev\
|
||||
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& git clone -b "main" https://github.com/citusdata/citus.git \
|
||||
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
|
||||
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
|
||||
else \
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-15-citus-12.0; \
|
||||
fi \
|
||||
&& pip3 install --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& PGHOME=/home/postgres \
|
||||
&& mkdir -p $PGHOME \
|
||||
&& chown postgres $PGHOME \
|
||||
@@ -24,6 +38,9 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
&& chmod 664 /etc/passwd \
|
||||
# Clean up
|
||||
&& apt-get remove -y git python3-pip python3-wheel \
|
||||
postgresql-server-dev-15 gcc make autoconf \
|
||||
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||
@@ -32,7 +49,9 @@ ADD entrypoint.sh /
|
||||
ENV PGSSLMODE=verify-ca PGSSLKEY=/etc/ssl/private/ssl-cert-snakeoil.key PGSSLCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem PGSSLROOTCERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
|
||||
RUN sed -i 's/^postgresql:/&\n basebackup:\n checkpoint: fast/' /entrypoint.sh \
|
||||
&& sed -i "s|^ postgresql:|&\n pg_hba:\n - local all all trust\n - hostssl replication all all md5 clientcert=$PGSSLMODE\n - hostssl all all all md5 clientcert=$PGSSLMODE\n parameters:\n max_connections: 100\n shared_buffers: 16MB\n ssl: 'on'\n ssl_ca_file: $PGSSLROOTCERT\n ssl_cert_file: $PGSSLCERT\n ssl_key_file: $PGSSLKEY\n citus.node_conninfo: 'sslrootcert=$PGSSLROOTCERT sslkey=$PGSSLKEY sslcert=$PGSSLCERT sslmode=$PGSSLMODE'|" /entrypoint.sh \
|
||||
&& sed -i "s|^ postgresql:|&\n parameters:\n max_connections: 100\n shared_buffers: 16MB\n ssl: 'on'\n ssl_ca_file: $PGSSLROOTCERT\n ssl_cert_file: $PGSSLCERT\n ssl_key_file: $PGSSLKEY\n citus.node_conninfo: 'sslrootcert=$PGSSLROOTCERT sslkey=$PGSSLKEY sslcert=$PGSSLCERT sslmode=$PGSSLMODE'|" /entrypoint.sh \
|
||||
&& sed -i 's/^ pg_hba:/&\n - local all all trust/' /entrypoint.sh \
|
||||
&& sed -i "s/^\(.*\) \(.*\) \(.*\) \(.*\) \(.*\) md5.*$/\1 hostssl \3 \4 all md5 clientcert=$PGSSLMODE/" /entrypoint.sh \
|
||||
&& sed -i "s#^ \(superuser\|replication\):#&\n sslmode: $PGSSLMODE\n sslkey: $PGSSLKEY\n sslcert: $PGSSLCERT\n sslrootcert: $PGSSLROOTCERT#" /entrypoint.sh
|
||||
|
||||
EXPOSE 5432 8008
|
||||
|
||||
@@ -12,15 +12,15 @@ bootstrap:
|
||||
dcs:
|
||||
postgresql:
|
||||
use_pg_rewind: true
|
||||
pg_hba:
|
||||
- host all all 0.0.0.0/0 md5
|
||||
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
|
||||
initdb:
|
||||
- auth-host: md5
|
||||
- auth-local: trust
|
||||
- encoding: UTF8
|
||||
- locale: en_US.UTF-8
|
||||
- data-checksums
|
||||
pg_hba:
|
||||
- host all all 0.0.0.0/0 md5
|
||||
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
|
||||
restapi:
|
||||
connect_address: '${PATRONI_KUBERNETES_POD_IP}:8008'
|
||||
postgresql:
|
||||
|
||||
+4
-1
@@ -16,7 +16,10 @@ def hiddenimports():
|
||||
a = Analysis(['patroni/__main__.py'],
|
||||
pathex=[],
|
||||
binaries=None,
|
||||
datas=None,
|
||||
datas=[
|
||||
('patroni/postgresql/available_parameters/*.yml', 'patroni/postgresql/available_parameters'),
|
||||
('patroni/postgresql/available_parameters/*.yaml', 'patroni/postgresql/available_parameters'),
|
||||
],
|
||||
hiddenimports=hiddenimports(),
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
|
||||
+34
-27
@@ -1,41 +1,48 @@
|
||||
import sys
|
||||
"""Define general variables and functions for :mod:`patroni`.
|
||||
|
||||
:var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables.
|
||||
:var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables.
|
||||
:var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work.
|
||||
:var MIN_PSYCOPG3: minimum version of :mod:`psycopg` required by Patroni to work.
|
||||
"""
|
||||
from typing import Iterator, Tuple
|
||||
|
||||
PATRONI_ENV_PREFIX = 'PATRONI_'
|
||||
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
|
||||
MIN_PSYCOPG2 = (2, 5, 4)
|
||||
MIN_PSYCOPG3 = (3, 0, 0)
|
||||
|
||||
|
||||
def fatal(string, *args):
|
||||
sys.stderr.write('FATAL: ' + string.format(*args) + '\n')
|
||||
sys.exit(1)
|
||||
def parse_version(version: str) -> Tuple[int, ...]:
|
||||
"""Convert *version* from human-readable format to tuple of integers.
|
||||
|
||||
.. note::
|
||||
Designed for easy comparison of software versions in Python.
|
||||
|
||||
def parse_version(version):
|
||||
def _parse_version(version):
|
||||
:param version: human-readable software version, e.g. ``2.5.4.dev1 (dt dec pq3 ext lo64)``.
|
||||
|
||||
:returns: tuple of *version* parts, each part as an integer.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> parse_version('2.5.4.dev1 (dt dec pq3 ext lo64)')
|
||||
(2, 5, 4)
|
||||
"""
|
||||
def _parse_version(version: str) -> Iterator[int]:
|
||||
"""Yield each part of a human-readable version string as an integer.
|
||||
|
||||
:param version: human-readable software version, e.g. ``2.5.4.dev1``.
|
||||
|
||||
:yields: each part of *version* as an integer.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> tuple(_parse_version('2.5.4.dev1'))
|
||||
(2, 5, 4)
|
||||
"""
|
||||
for e in version.split('.'):
|
||||
try:
|
||||
yield int(e)
|
||||
except ValueError:
|
||||
break
|
||||
return tuple(_parse_version(version.split(' ')[0]))
|
||||
|
||||
|
||||
# We pass MIN_PSYCOPG2 and parse_version as arguments to simplify usage of check_psycopg from the setup.py
|
||||
def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version):
|
||||
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
|
||||
|
||||
try:
|
||||
from psycopg2 import __version__
|
||||
if _parse_version(__version__) >= _min_psycopg2:
|
||||
return
|
||||
version_str = __version__.split(' ')[0]
|
||||
except ImportError:
|
||||
version_str = None
|
||||
|
||||
try:
|
||||
from psycopg import __version__
|
||||
except ImportError:
|
||||
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
|
||||
if version_str:
|
||||
error += ', but only psycopg2=={0} is available'.format(version_str)
|
||||
fatal(error)
|
||||
|
||||
+250
-38
@@ -1,16 +1,54 @@
|
||||
"""Patroni main entry point.
|
||||
|
||||
Implement ``patroni`` main daemon and expose its entry point.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
from patroni.daemon import AbstractPatroniDaemon, abstract_main
|
||||
from argparse import Namespace
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3, parse_version
|
||||
from patroni.daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser
|
||||
from patroni.tags import Tags
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Patroni(AbstractPatroniDaemon):
|
||||
class Patroni(AbstractPatroniDaemon, Tags):
|
||||
"""Implement ``patroni`` command daemon.
|
||||
|
||||
def __init__(self, config):
|
||||
:ivar version: Patroni version.
|
||||
:ivar dcs: DCS object.
|
||||
:ivar watchdog: watchdog handler, if configured to use watchdog.
|
||||
:ivar postgresql: managed Postgres instance.
|
||||
:ivar api: REST API server instance of this node.
|
||||
:ivar request: wrapper for performing HTTP requests.
|
||||
:ivar ha: HA handler.
|
||||
:ivar next_run: time when to run the next HA loop cycle.
|
||||
:ivar scheduled_restart: when a restart has been scheduled to occur, if any. In that case, should contain two keys:
|
||||
* ``schedule``: timestamp when restart should occur;
|
||||
* ``postmaster_start_time``: timestamp when Postgres was last started.
|
||||
"""
|
||||
|
||||
def __init__(self, config: 'Config') -> None:
|
||||
"""Create a :class:`Patroni` instance with the given *config*.
|
||||
|
||||
Get a connection to the DCS, configure watchdog (if required), set up Patroni interface with Postgres, configure
|
||||
the HA loop and bring the REST API up.
|
||||
|
||||
.. note::
|
||||
Expected to be instantiated and run through :func:`~patroni.daemon.abstract_main`.
|
||||
|
||||
:param config: Patroni configuration.
|
||||
"""
|
||||
from patroni.api import RestApiServer
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.ha import Ha
|
||||
@@ -23,19 +61,33 @@ class Patroni(AbstractPatroniDaemon):
|
||||
|
||||
self.version = __version__
|
||||
self.dcs = get_dcs(self.config)
|
||||
self.request = PatroniRequest(self.config, True)
|
||||
|
||||
self.ensure_unique_name()
|
||||
|
||||
self.watchdog = Watchdog(self.config)
|
||||
self.load_dynamic_configuration()
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'])
|
||||
self.api = RestApiServer(self, self.config['restapi'])
|
||||
self.request = PatroniRequest(self.config, True)
|
||||
self.ha = Ha(self)
|
||||
|
||||
self.tags = self.get_tags()
|
||||
self._tags = self._get_tags()
|
||||
self.next_run = time.time()
|
||||
self.scheduled_restart = {}
|
||||
self.scheduled_restart: Dict[str, Any] = {}
|
||||
|
||||
def load_dynamic_configuration(self):
|
||||
def load_dynamic_configuration(self) -> None:
|
||||
"""Load Patroni dynamic configuration.
|
||||
|
||||
Load dynamic configuration from the DCS, if `/config` key is available in the DCS, otherwise fall back to
|
||||
``bootstrap.dcs`` section from the configuration file.
|
||||
|
||||
If the DCS connection fails returning the exception :class:`~patroni.exceptions.DCSError` an attempt will be
|
||||
remade every 5 seconds.
|
||||
|
||||
.. note::
|
||||
This method is called only once, at the time when Patroni is started.
|
||||
"""
|
||||
from patroni.exceptions import DCSError
|
||||
while True:
|
||||
try:
|
||||
@@ -53,23 +105,51 @@ class Patroni(AbstractPatroniDaemon):
|
||||
logger.warning('Can not get cluster from dcs')
|
||||
time.sleep(5)
|
||||
|
||||
def get_tags(self):
|
||||
return {tag: value for tag, value in self.config.get('tags', {}).items()
|
||||
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
|
||||
def ensure_unique_name(self) -> None:
|
||||
"""A helper method to prevent splitbrain from operator naming error."""
|
||||
from patroni.dcs import Member
|
||||
|
||||
@property
|
||||
def nofailover(self):
|
||||
return bool(self.tags.get('nofailover', False))
|
||||
cluster = self.dcs.get_cluster()
|
||||
if not cluster:
|
||||
return
|
||||
member = cluster.get_member(self.config['name'], False)
|
||||
if not isinstance(member, Member):
|
||||
return
|
||||
try:
|
||||
# Silence annoying WARNING: Retrying (...) messages when Patroni is quickly restarted.
|
||||
# At this moment we don't have custom log levels configured and hence shouldn't lose anything useful.
|
||||
self.logger.update_loggers({'urllib3.connectionpool': 'ERROR'})
|
||||
_ = self.request(member, endpoint="/liveness", timeout=3)
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
self.logger.update_loggers({})
|
||||
|
||||
@property
|
||||
def nosync(self):
|
||||
return bool(self.tags.get('nosync', False))
|
||||
def _get_tags(self) -> Dict[str, Any]:
|
||||
"""Get tags configured for this node, if any.
|
||||
|
||||
def reload_config(self, sighup=False, local=False):
|
||||
:returns: a dictionary of tags set for this node.
|
||||
"""
|
||||
return self._filter_tags(self.config.get('tags', {}))
|
||||
|
||||
def reload_config(self, sighup: bool = False, local: Optional[bool] = False) -> None:
|
||||
"""Apply new configuration values for ``patroni`` daemon.
|
||||
|
||||
Reload:
|
||||
* Cached tags;
|
||||
* Request wrapper configuration;
|
||||
* REST API configuration;
|
||||
* Watchdog configuration;
|
||||
* Postgres configuration;
|
||||
* DCS configuration.
|
||||
|
||||
:param sighup: if it is related to a SIGHUP signal.
|
||||
:param local: if there has been changes to the local configuration file.
|
||||
"""
|
||||
try:
|
||||
super(Patroni, self).reload_config(sighup, local)
|
||||
if local:
|
||||
self.tags = self.get_tags()
|
||||
self._tags = self._get_tags()
|
||||
self.request.reload_config(self.config)
|
||||
if local or sighup and self.api.reload_local_certificate():
|
||||
self.api.reload_config(self.config['restapi'])
|
||||
@@ -80,14 +160,16 @@ class Patroni(AbstractPatroniDaemon):
|
||||
logger.exception('Failed to reload config_file=%s', self.config.config_file)
|
||||
|
||||
@property
|
||||
def replicatefrom(self):
|
||||
return self.tags.get('replicatefrom')
|
||||
def tags(self) -> Dict[str, Any]:
|
||||
"""Tags configured for this node, if any."""
|
||||
return self._tags
|
||||
|
||||
@property
|
||||
def noloadbalance(self):
|
||||
return bool(self.tags.get('noloadbalance', False))
|
||||
def schedule_next_run(self) -> None:
|
||||
"""Schedule the next run of the ``patroni`` daemon main loop.
|
||||
|
||||
def schedule_next_run(self):
|
||||
Next run is scheduled based on previous run plus value of ``loop_wait`` configuration from DCS. If that has
|
||||
already been exceeded, run the next cycle immediately.
|
||||
"""
|
||||
self.next_run += self.dcs.loop_wait
|
||||
current_time = time.time()
|
||||
nap_time = self.next_run - current_time
|
||||
@@ -100,12 +182,22 @@ class Patroni(AbstractPatroniDaemon):
|
||||
elif self.ha.watch(nap_time):
|
||||
self.next_run = time.time()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
"""Run ``patroni`` daemon process main loop.
|
||||
|
||||
Start the REST API and keep running HA cycles every ``loop_wait`` seconds.
|
||||
"""
|
||||
self.api.start()
|
||||
self.next_run = time.time()
|
||||
super(Patroni, self).run()
|
||||
|
||||
def _run_cycle(self):
|
||||
def _run_cycle(self) -> None:
|
||||
"""Run a cycle of the ``patroni`` daemon main loop.
|
||||
|
||||
Run an HA cycle and schedule the next cycle run. If any dynamic configuration change request is detected, apply
|
||||
the change and cache the new dynamic configuration values in ``patroni.dynamic.json`` file under Postgres data
|
||||
directory.
|
||||
"""
|
||||
logger.info(self.ha.run_cycle())
|
||||
|
||||
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
|
||||
@@ -117,7 +209,11 @@ class Patroni(AbstractPatroniDaemon):
|
||||
|
||||
self.schedule_next_run()
|
||||
|
||||
def _shutdown(self):
|
||||
def _shutdown(self) -> None:
|
||||
"""Perform shutdown of ``patroni`` daemon process.
|
||||
|
||||
Shut down the REST API and the HA handler.
|
||||
"""
|
||||
try:
|
||||
self.api.shutdown()
|
||||
except Exception:
|
||||
@@ -128,27 +224,138 @@ class Patroni(AbstractPatroniDaemon):
|
||||
logger.exception('Exception during Ha.shutdown')
|
||||
|
||||
|
||||
def patroni_main():
|
||||
def patroni_main(configfile: str) -> None:
|
||||
"""Configure and start ``patroni`` main daemon process.
|
||||
|
||||
:param configfile: path to Patroni configuration file.
|
||||
"""
|
||||
abstract_main(Patroni, configfile)
|
||||
|
||||
|
||||
def process_arguments() -> Namespace:
|
||||
"""Process command-line arguments.
|
||||
|
||||
Create a basic command-line parser through :func:`~patroni.daemon.get_base_arg_parser`, extend its capabilities by
|
||||
adding these flags and parse command-line arguments.:
|
||||
|
||||
* ``--validate-config`` -- used to validate the Patroni configuration file
|
||||
* ``--generate-config`` -- used to generate Patroni configuration from a running PostgreSQL instance
|
||||
* ``--generate-sample-config`` -- used to generate a sample Patroni configuration
|
||||
|
||||
.. note::
|
||||
If running with ``--generate-config``, ``--generate-sample-config`` or ``--validate-flag`` will exit
|
||||
after generating or validating configuration.
|
||||
|
||||
:returns: parsed arguments, if not running with ``--validate-config`` flag.
|
||||
"""
|
||||
from patroni.config_generator import generate_config
|
||||
|
||||
parser = get_base_arg_parser()
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
|
||||
group.add_argument('--generate-sample-config', action='store_true',
|
||||
help='Generate a sample Patroni yaml configuration file')
|
||||
group.add_argument('--generate-config', action='store_true',
|
||||
help='Generate a Patroni yaml configuration file for a running instance')
|
||||
parser.add_argument('--dsn', help='Optional DSN string of the instance to be used as a source \
|
||||
for config generation. Superuser connection is required.')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.generate_sample_config:
|
||||
generate_config(args.configfile, True, None)
|
||||
sys.exit(0)
|
||||
elif args.generate_config:
|
||||
generate_config(args.configfile, False, args.dsn)
|
||||
sys.exit(0)
|
||||
elif args.validate_config:
|
||||
from patroni.validator import schema
|
||||
from patroni.config import Config, ConfigParseError
|
||||
|
||||
try:
|
||||
Config(args.configfile, validator=schema)
|
||||
sys.exit()
|
||||
except ConfigParseError as e:
|
||||
sys.exit(e.value)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def check_psycopg() -> None:
|
||||
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
|
||||
|
||||
.. note::
|
||||
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
|
||||
|
||||
If nothing meeting the requirements is found, then exit with a fatal message.
|
||||
"""
|
||||
min_psycopg2_str = '.'.join(map(str, MIN_PSYCOPG2))
|
||||
min_psycopg3_str = '.'.join(map(str, MIN_PSYCOPG3))
|
||||
|
||||
available_versions: List[str] = []
|
||||
|
||||
# try psycopg2
|
||||
try:
|
||||
from psycopg2 import __version__
|
||||
if parse_version(__version__) >= MIN_PSYCOPG2:
|
||||
return
|
||||
available_versions.append('psycopg2=={0}'.format(__version__.split(' ')[0]))
|
||||
except ImportError:
|
||||
logger.debug('psycopg2 module is not available')
|
||||
|
||||
# try psycopg3
|
||||
try:
|
||||
from psycopg import __version__
|
||||
if parse_version(__version__) >= MIN_PSYCOPG3:
|
||||
return
|
||||
available_versions.append('psycopg=={0}'.format(__version__.split(' ')[0]))
|
||||
except ImportError:
|
||||
logger.debug('psycopg module is not available')
|
||||
|
||||
error = f'FATAL: Patroni requires psycopg2>={min_psycopg2_str}, psycopg2-binary, or psycopg>={min_psycopg3_str}'
|
||||
if available_versions:
|
||||
error += ', but only {0} {1} available'.format(
|
||||
' and '.join(available_versions),
|
||||
'is' if len(available_versions) == 1 else 'are')
|
||||
sys.exit(error)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entrypoint of :mod:`patroni.__main__`.
|
||||
|
||||
Process command-line arguments, ensure :mod:`psycopg2` (or :mod:`psycopg`) attendee the pre-requisites and start
|
||||
``patroni`` daemon process.
|
||||
|
||||
.. note::
|
||||
If running through a Docker container, make the main process take care of init process duties and run
|
||||
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
|
||||
to ``patroni`` daemon process.
|
||||
"""
|
||||
from multiprocessing import freeze_support
|
||||
from patroni.validator import schema
|
||||
|
||||
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
|
||||
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
|
||||
freeze_support()
|
||||
abstract_main(Patroni, schema)
|
||||
|
||||
check_psycopg()
|
||||
|
||||
args = process_arguments()
|
||||
|
||||
def main():
|
||||
if os.getpid() != 1:
|
||||
from patroni import check_psycopg
|
||||
|
||||
check_psycopg()
|
||||
return patroni_main()
|
||||
return patroni_main(args.configfile)
|
||||
|
||||
# Patroni started with PID=1, it looks like we are in the container
|
||||
from types import FrameType
|
||||
pid = 0
|
||||
|
||||
# Looks like we are in a docker, so we will act like init
|
||||
def sigchld_handler(signo, stack_frame):
|
||||
def sigchld_handler(signo: int, stack_frame: Optional[FrameType]) -> None:
|
||||
"""Handle ``SIGCHLD`` received by main process from ``patroni`` daemon when the daemon terminates.
|
||||
|
||||
:param signo: signal number.
|
||||
:param stack_frame: current stack frame.
|
||||
"""
|
||||
try:
|
||||
# log exit code of all children processes, and break loop when there is none left
|
||||
while True:
|
||||
ret = os.waitpid(-1, os.WNOHANG)
|
||||
if ret == (0, 0):
|
||||
@@ -158,7 +365,12 @@ def main():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def passtochild(signo, stack_frame):
|
||||
def passtochild(signo: int, stack_frame: Optional[FrameType]) -> None:
|
||||
"""Forward a signal *signo* from main process to child process.
|
||||
|
||||
:param signo: signal number.
|
||||
:param stack_frame: current stack frame.
|
||||
"""
|
||||
if pid:
|
||||
os.kill(pid, signo)
|
||||
|
||||
@@ -173,7 +385,7 @@ def main():
|
||||
signal.signal(signal.SIGTERM, passtochild)
|
||||
|
||||
import multiprocessing
|
||||
patroni = multiprocessing.Process(target=patroni_main)
|
||||
patroni = multiprocessing.Process(target=patroni_main, args=(args.configfile,))
|
||||
patroni.start()
|
||||
pid = patroni.pid
|
||||
patroni.join()
|
||||
|
||||
+1011
-197
File diff suppressed because it is too large
Load Diff
+125
-27
@@ -1,5 +1,11 @@
|
||||
"""Implement facilities for executing asynchronous tasks."""
|
||||
import logging
|
||||
|
||||
from threading import Event, Lock, RLock, Thread
|
||||
from types import TracebackType
|
||||
from typing import Any, Callable, Optional, Tuple, Type
|
||||
|
||||
from .postgresql.cancellable import CancellableSubprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,65 +14,110 @@ class CriticalTask(object):
|
||||
"""Represents a critical task in a background process that we either need to cancel or get the result of.
|
||||
|
||||
Fields of this object may be accessed only when holding a lock on it. To perform the critical task the background
|
||||
thread must, while holding lock on this object, check `is_cancelled` flag, run the task and mark the task as
|
||||
complete using `complete()`.
|
||||
thread must, while holding lock on this object, check ``is_cancelled`` flag, run the task and mark the task as
|
||||
complete using :func:`complete`.
|
||||
|
||||
The main thread must hold async lock to prevent the task from completing, hold lock on critical task object,
|
||||
call cancel. If the task has completed `cancel()` will return False and `result` field will contain the result of
|
||||
the task. When cancel returns True it is guaranteed that the background task will notice the `is_cancelled` flag.
|
||||
call :func:`cancel`. If the task has completed :func:`cancel` will return ``False`` and ``result`` field will
|
||||
contain the result of the task. When :func:`cancel` returns ``True`` it is guaranteed that the background task will
|
||||
notice the ``is_cancelled`` flag.
|
||||
|
||||
:ivar is_cancelled: if the critical task has been cancelled.
|
||||
:ivar result: contains the result of the task, if it has already been completed.
|
||||
"""
|
||||
def __init__(self):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create a new instance of :class:`CriticalTask`.
|
||||
|
||||
Instantiate the lock and the task control attributes.
|
||||
"""
|
||||
self._lock = Lock()
|
||||
self.is_cancelled = False
|
||||
self.result = None
|
||||
|
||||
def reset(self):
|
||||
def reset(self) -> None:
|
||||
"""Must be called every time the background task is finished.
|
||||
|
||||
Must be called from async thread. Caller must hold lock on async executor when calling."""
|
||||
.. note::
|
||||
Must be called from async thread. Caller must hold lock on async executor when calling.
|
||||
"""
|
||||
self.is_cancelled = False
|
||||
self.result = None
|
||||
|
||||
def cancel(self):
|
||||
"""Tries to cancel the task, returns True if the task has already run.
|
||||
def cancel(self) -> bool:
|
||||
"""Tries to cancel the task.
|
||||
|
||||
Caller must hold lock on async executor and the task when calling."""
|
||||
.. note::
|
||||
Caller must hold lock on async executor and the task when calling.
|
||||
|
||||
:returns: ``False`` if the task has already run, or ``True`` it has been cancelled.
|
||||
"""
|
||||
if self.result is not None:
|
||||
return False
|
||||
self.is_cancelled = True
|
||||
return True
|
||||
|
||||
def complete(self, result):
|
||||
"""Mark task as completed along with a result.
|
||||
def complete(self, result: Any) -> None:
|
||||
"""Mark task as completed along with a *result*.
|
||||
|
||||
Must be called from async thread. Caller must hold lock on task when calling."""
|
||||
.. note::
|
||||
Must be called from async thread. Caller must hold lock on task when calling.
|
||||
"""
|
||||
self.result = result
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> 'CriticalTask':
|
||||
"""Acquire the object lock when entering the context manager."""
|
||||
self._lock.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
def __exit__(self, exc_type: Optional[Type[BaseException]],
|
||||
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
|
||||
"""Release the object lock when exiting the context manager."""
|
||||
self._lock.release()
|
||||
|
||||
|
||||
class AsyncExecutor(object):
|
||||
"""Asynchronous executor of (long) tasks.
|
||||
|
||||
def __init__(self, cancellable, ha_wakeup):
|
||||
:ivar critical_task: a :class:`CriticalTask` instance to handle execution of critical background tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, cancellable: CancellableSubprocess, ha_wakeup: Callable[..., None]) -> None:
|
||||
"""Create a new instance of :class:`AsyncExecutor`.
|
||||
|
||||
Configure the given *cancellable* and *ha_wakeup*, initializes the control attributes, and instantiate the lock
|
||||
and event objects that are used to access attributes and manage communication between threads.
|
||||
|
||||
:param cancellable: a subprocess that supports being cancelled.
|
||||
:param ha_wakeup: function to wake up the HA loop.
|
||||
"""
|
||||
self._cancellable = cancellable
|
||||
self._ha_wakeup = ha_wakeup
|
||||
self._thread_lock = RLock()
|
||||
self._scheduled_action = None
|
||||
self._scheduled_action: Optional[str] = None
|
||||
self._scheduled_action_lock = RLock()
|
||||
self._is_cancelled = False
|
||||
self._finish_event = Event()
|
||||
self.critical_task = CriticalTask()
|
||||
|
||||
@property
|
||||
def busy(self):
|
||||
def busy(self) -> bool:
|
||||
"""``True`` if there is an action scheduled to occur, else ``False``."""
|
||||
return self.scheduled_action is not None
|
||||
|
||||
def schedule(self, action):
|
||||
def schedule(self, action: str) -> Optional[str]:
|
||||
"""Schedule *action* to be executed.
|
||||
|
||||
.. note::
|
||||
Must be called before executing a task.
|
||||
|
||||
.. note::
|
||||
*action* can only be scheduled if there is no other action currently scheduled.
|
||||
|
||||
:param action: action to be executed.
|
||||
|
||||
:returns: ``None`` if *action* has been successfully scheduled, or the previously scheduled action, if any.
|
||||
"""
|
||||
with self._scheduled_action_lock:
|
||||
if self._scheduled_action is not None:
|
||||
return self._scheduled_action
|
||||
@@ -76,15 +127,33 @@ class AsyncExecutor(object):
|
||||
return None
|
||||
|
||||
@property
|
||||
def scheduled_action(self):
|
||||
def scheduled_action(self) -> Optional[str]:
|
||||
"""The currently scheduled action, if any, else ``None``."""
|
||||
with self._scheduled_action_lock:
|
||||
return self._scheduled_action
|
||||
|
||||
def reset_scheduled_action(self):
|
||||
def reset_scheduled_action(self) -> None:
|
||||
"""Unschedule a previously scheduled action, if any.
|
||||
|
||||
.. note::
|
||||
Must be called once the scheduled task finishes or is cancelled.
|
||||
"""
|
||||
with self._scheduled_action_lock:
|
||||
self._scheduled_action = None
|
||||
|
||||
def run(self, func, args=()):
|
||||
def run(self, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> Optional[Any]:
|
||||
"""Run *func* with *args*.
|
||||
|
||||
.. note::
|
||||
Expected to be executed through a thread.
|
||||
|
||||
:param func: function to be run. If it returns anything other than ``None``, HA loop will be woken up at the end
|
||||
of :func:`run` execution.
|
||||
:param args: arguments to be passed to *func*.
|
||||
|
||||
:returns: ``None`` if *func* execution has been cancelled or faced any exception, otherwise the result of
|
||||
*func*.
|
||||
"""
|
||||
wakeup = False
|
||||
try:
|
||||
with self:
|
||||
@@ -107,16 +176,37 @@ class AsyncExecutor(object):
|
||||
if wakeup is not None:
|
||||
self._ha_wakeup()
|
||||
|
||||
def run_async(self, func, args=()):
|
||||
def run_async(self, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> None:
|
||||
"""Start an async thread that runs *func* with *args*.
|
||||
|
||||
:param func: function to be run. Will be passed through args to :class:`~threading.Thread` with a target of
|
||||
:func:`run`.
|
||||
:param args: arguments to be passed along to :class:`~threading.Thread` with *func*.
|
||||
|
||||
"""
|
||||
Thread(target=self.run, args=(func, args)).start()
|
||||
|
||||
def try_run_async(self, action, func, args=()):
|
||||
def try_run_async(self, action: str, func: Callable[..., Any], args: Tuple[Any, ...] = ()) -> Optional[str]:
|
||||
"""Try to run an async task, if none is currently being executed.
|
||||
|
||||
:param action: name of the task to be executed.
|
||||
:param func: actual function that performs the task *action*.
|
||||
:param args: arguments to be passed to *func*.
|
||||
|
||||
:returns: ``None`` if *func* was scheduled successfully, otherwise an error message informing of an already
|
||||
ongoing task.
|
||||
"""
|
||||
prev = self.schedule(action)
|
||||
if prev is None:
|
||||
return self.run_async(func, args)
|
||||
return 'Failed to run {0}, {1} is already in progress'.format(action, prev)
|
||||
|
||||
def cancel(self):
|
||||
def cancel(self) -> None:
|
||||
"""Request cancellation of a scheduled async task, if any.
|
||||
|
||||
.. note::
|
||||
Wait until task is cancelled before returning control to caller.
|
||||
"""
|
||||
with self:
|
||||
with self._scheduled_action_lock:
|
||||
if self._scheduled_action is None:
|
||||
@@ -130,8 +220,16 @@ class AsyncExecutor(object):
|
||||
with self:
|
||||
self.reset_scheduled_action()
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> 'AsyncExecutor':
|
||||
"""Acquire the thread lock when entering the context manager."""
|
||||
self._thread_lock.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
def __exit__(self, exc_type: Optional[Type[BaseException]],
|
||||
exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None:
|
||||
"""Release the thread lock when exiting the context manager.
|
||||
|
||||
.. note::
|
||||
The arguments are not used, but we need them to match the expected method signature.
|
||||
"""
|
||||
self._thread_lock.release()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Patroni custom object types somewhat like :mod:`collections` module.
|
||||
|
||||
Provides a case insensitive :class:`dict` and :class:`set` object types.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Collection, Dict, Iterator, KeysView, MutableMapping, MutableSet, Optional
|
||||
|
||||
|
||||
class CaseInsensitiveSet(MutableSet[str]):
|
||||
"""A case-insensitive :class:`set`-like object.
|
||||
|
||||
Implements all methods and operations of :class:`~typing.MutableSet`. All values are expected to be strings.
|
||||
The structure remembers the case of the last value set, however, contains testing is case insensitive.
|
||||
"""
|
||||
|
||||
def __init__(self, values: Optional[Collection[str]] = None) -> None:
|
||||
"""Create a new instance of :class:`CaseInsensitiveSet` with the given *values*.
|
||||
|
||||
:param values: values to be added to the set.
|
||||
"""
|
||||
self._values: Dict[str, str] = {}
|
||||
for v in values or ():
|
||||
self.add(v)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Get a string representation of the set.
|
||||
|
||||
Provide a helpful way of recreating the set.
|
||||
|
||||
:returns: representation of the set, showing its values.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> repr(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2'))) # doctest: +ELLIPSIS
|
||||
"<CaseInsensitiveSet('1', 'TESt', 'test2') at ..."
|
||||
"""
|
||||
return '<{0}{1} at {2:x}>'.format(type(self).__name__, tuple(self._values.values()), id(self))
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Get set values for printing.
|
||||
|
||||
:returns: set of values in string format.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> str(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2'))) # doctest: +SKIP
|
||||
"{'TESt', 'test2', '1'}"
|
||||
"""
|
||||
return str(set(self._values.values()))
|
||||
|
||||
def __contains__(self, value: str) -> bool:
|
||||
"""Check if set contains *value*.
|
||||
|
||||
The check is performed case-insensitively.
|
||||
|
||||
:param value: value to be checked.
|
||||
|
||||
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
|
||||
"""
|
||||
return value.lower() in self._values
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
"""Iterate over the values in this set.
|
||||
|
||||
:yields: values from set.
|
||||
"""
|
||||
return iter(self._values.values())
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get the length of this set.
|
||||
|
||||
:returns: number of values in the set.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> len(CaseInsensitiveSet(('1', 'test', 'Test', 'TESt', 'test2')))
|
||||
3
|
||||
"""
|
||||
return len(self._values)
|
||||
|
||||
def add(self, value: str) -> None:
|
||||
"""Add *value* to this set.
|
||||
|
||||
Search is performed case-insensitively. If *value* is already in the set, overwrite it with *value*, so we
|
||||
"remember" the last case of *value*.
|
||||
|
||||
:param value: value to be added to the set.
|
||||
"""
|
||||
self._values[value.lower()] = value
|
||||
|
||||
def discard(self, value: str) -> None:
|
||||
"""Remove *value* from this set.
|
||||
|
||||
Search is performed case-insensitively. If *value* is not present in the set, no exception is raised.
|
||||
|
||||
:param value: value to be removed from the set.
|
||||
"""
|
||||
self._values.pop(value.lower(), None)
|
||||
|
||||
def issubset(self, other: 'CaseInsensitiveSet') -> bool:
|
||||
"""Check if this set is a subset of *other*.
|
||||
|
||||
:param other: another set to be compared with this set.
|
||||
:returns: ``True`` if this set is a subset of *other*, else ``False``.
|
||||
"""
|
||||
return self <= other
|
||||
|
||||
|
||||
class CaseInsensitiveDict(MutableMapping[str, Any]):
|
||||
"""A case-insensitive :class:`dict`-like object.
|
||||
|
||||
Implements all methods and operations of :class:`~typing.MutableMapping` as well as :class:`dict`'s
|
||||
:func:`~dict.copy`. All keys are expected to be strings. The structure remembers the case of the last key to be set,
|
||||
and :func:`iter`, :func:`dict.keys`, :func:`dict.items`, :func:`dict.iterkeys`, and :func:`dict.iteritems` will
|
||||
contain case-sensitive keys. However, querying and contains testing is case insensitive.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Create a new instance of :class:`CaseInsensitiveDict` with the given *data*.
|
||||
|
||||
:param data: initial dictionary to create a :class:`CaseInsensitiveDict` from.
|
||||
"""
|
||||
self._values: OrderedDict[str, Any] = OrderedDict()
|
||||
self.update(data or {})
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
"""Assign *value* to *key* in this dict.
|
||||
|
||||
*key* is searched/stored case-insensitively in the dict. The corresponding value in the dict is a tuple of:
|
||||
* original *key*;
|
||||
* *value*.
|
||||
|
||||
:param key: key to be created or updated in the dict.
|
||||
:param value: value for *key*.
|
||||
"""
|
||||
self._values[key.lower()] = (key, value)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get the value corresponding to *key*.
|
||||
|
||||
*key* is searched case-insensitively in the dict.
|
||||
|
||||
.. note:
|
||||
If *key* is not present in the dict, :class:`KeyError` will be triggered.
|
||||
|
||||
:param key: key to be searched in the dict.
|
||||
|
||||
:returns: value corresponding to *key*.
|
||||
"""
|
||||
return self._values[key.lower()][1]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
"""Remove *key* from this dict.
|
||||
|
||||
*key* is searched case-insensitively in the dict.
|
||||
|
||||
.. note:
|
||||
If *key* is not present in the dict, :class:`KeyError` will be triggered.
|
||||
|
||||
:param key: key to be removed from the dict.
|
||||
"""
|
||||
del self._values[key.lower()]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
"""Iterate over keys of this dict.
|
||||
|
||||
:yields: each key present in the dict. Yields each key with its last case that has been stored.
|
||||
"""
|
||||
return iter(key for key, _ in self._values.values())
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get the length of this dict.
|
||||
|
||||
:returns: number of keys in the dict.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> len(CaseInsensitiveDict({'a': 'b', 'A': 'B', 'c': 'd'}))
|
||||
2
|
||||
"""
|
||||
return len(self._values)
|
||||
|
||||
def copy(self) -> 'CaseInsensitiveDict':
|
||||
"""Create a copy of this dict.
|
||||
|
||||
:return: a new dict object with the same keys and values of this dict.
|
||||
"""
|
||||
return CaseInsensitiveDict({v[0]: v[1] for v in self._values.values()})
|
||||
|
||||
def keys(self) -> KeysView[str]:
|
||||
"""Return a new view of the dict's keys.
|
||||
|
||||
:returns: a set-like object providing a view on the dict's keys
|
||||
"""
|
||||
return self._values.keys()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Get a string representation of the dict.
|
||||
|
||||
Provide a helpful way of recreating the dict.
|
||||
|
||||
:returns: representation of the dict, showing its keys and values.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> repr(CaseInsensitiveDict({'a': 'b', 'A': 'B', 'c': 'd'})) # doctest: +ELLIPSIS
|
||||
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
|
||||
"""
|
||||
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
|
||||
+616
-113
@@ -1,18 +1,23 @@
|
||||
"""Facilities related to Patroni configuration."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import six
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from patroni import PATRONI_ENV_PREFIX
|
||||
from patroni.exceptions import ConfigParseError
|
||||
from patroni.dcs import ClusterConfig
|
||||
from patroni.postgresql.config import CaseInsensitiveDict, ConfigHandler
|
||||
from patroni.utils import deep_compare, parse_bool, parse_int, patch_config
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
|
||||
|
||||
from . import PATRONI_ENV_PREFIX
|
||||
from .collections import CaseInsensitiveDict
|
||||
from .dcs import ClusterConfig, Cluster
|
||||
from .exceptions import ConfigParseError
|
||||
from .file_perm import pg_perm
|
||||
from .postgresql.config import ConfigHandler
|
||||
from .validator import IntValidator
|
||||
from .utils import deep_compare, parse_bool, parse_int, patch_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,49 +36,203 @@ _AUTH_ALLOWED_PARAMETERS = (
|
||||
)
|
||||
|
||||
|
||||
def default_validator(conf):
|
||||
def default_validator(conf: Dict[str, Any]) -> List[str]:
|
||||
"""Ensure *conf* is not empty.
|
||||
|
||||
Designed to be used as default validator for :class:`Config` objects, if no specific validator is provided.
|
||||
|
||||
:param conf: configuration to be validated.
|
||||
|
||||
:returns: an empty list -- :class:`Config` expects the validator to return a list of 0 or more issues found while
|
||||
validating the configuration.
|
||||
|
||||
:raises:
|
||||
:class:`ConfigParseError`: if *conf* is empty.
|
||||
"""
|
||||
if not conf:
|
||||
raise ConfigParseError("Config is empty.")
|
||||
return []
|
||||
|
||||
|
||||
class GlobalConfig(object):
|
||||
"""A class that wraps global configuration and provides convenient methods to access/check values.
|
||||
|
||||
It is instantiated either by calling :func:`get_global_config` or :meth:`Config.get_global_config`, which picks
|
||||
either a configuration from provided :class:`Cluster` object (the most up-to-date) or from the
|
||||
local cache if :class:`ClusterConfig` is not initialized or doesn't have a valid config.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
"""Initialize :class:`GlobalConfig` object with given *config*.
|
||||
|
||||
:param config: current configuration either from
|
||||
:class:`ClusterConfig` or from :func:`Config.dynamic_configuration`.
|
||||
"""
|
||||
self.__config = config
|
||||
|
||||
def get(self, name: str) -> Any:
|
||||
"""Gets global configuration value by *name*.
|
||||
|
||||
:param name: parameter name.
|
||||
|
||||
:returns: configuration value or ``None`` if it is missing.
|
||||
"""
|
||||
return self.__config.get(name)
|
||||
|
||||
def check_mode(self, mode: str) -> bool:
|
||||
"""Checks whether the certain parameter is enabled.
|
||||
|
||||
:param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and
|
||||
so on.
|
||||
|
||||
:returns: ``True`` if parameter *mode* is enabled in the global configuration.
|
||||
"""
|
||||
return bool(parse_bool(self.__config.get(mode)))
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
"""``True`` if cluster is in maintenance mode."""
|
||||
return self.check_mode('pause')
|
||||
|
||||
@property
|
||||
def is_synchronous_mode(self) -> bool:
|
||||
"""``True`` if synchronous replication is requested and it is not a standby cluster config."""
|
||||
return self.check_mode('synchronous_mode') and not self.is_standby_cluster
|
||||
|
||||
@property
|
||||
def is_synchronous_mode_strict(self) -> bool:
|
||||
"""``True`` if at least one synchronous node is required."""
|
||||
return self.check_mode('synchronous_mode_strict')
|
||||
|
||||
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
|
||||
"""Get ``standby_cluster`` configuration.
|
||||
|
||||
:returns: a copy of ``standby_cluster`` configuration.
|
||||
"""
|
||||
return deepcopy(self.get('standby_cluster'))
|
||||
|
||||
@property
|
||||
def is_standby_cluster(self) -> bool:
|
||||
"""``True`` if global configuration has a valid ``standby_cluster`` section."""
|
||||
config = self.get_standby_cluster_config()
|
||||
return isinstance(config, dict) and\
|
||||
bool(config.get('host') or config.get('port') or config.get('restore_command'))
|
||||
|
||||
def get_int(self, name: str, default: int = 0) -> int:
|
||||
"""Gets current value of *name* from the global configuration and try to return it as :class:`int`.
|
||||
|
||||
:param name: name of the parameter.
|
||||
:param default: default value if *name* is not in the configuration or invalid.
|
||||
|
||||
:returns: currently configured value of *name* from the global configuration or *default* if it is not set or
|
||||
invalid.
|
||||
"""
|
||||
ret = parse_int(self.get(name))
|
||||
return default if ret is None else ret
|
||||
|
||||
@property
|
||||
def min_synchronous_nodes(self) -> int:
|
||||
"""The minimal number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not."""
|
||||
return 1 if self.is_synchronous_mode_strict else 0
|
||||
|
||||
@property
|
||||
def synchronous_node_count(self) -> int:
|
||||
"""Currently configured value of ``synchronous_node_count`` from the global configuration.
|
||||
|
||||
Assume ``1`` if it is not set or invalid.
|
||||
"""
|
||||
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
|
||||
|
||||
@property
|
||||
def maximum_lag_on_failover(self) -> int:
|
||||
"""Currently configured value of ``maximum_lag_on_failover`` from the global configuration.
|
||||
|
||||
Assume ``1048576`` if it is not set or invalid.
|
||||
"""
|
||||
return self.get_int('maximum_lag_on_failover', 1048576)
|
||||
|
||||
@property
|
||||
def maximum_lag_on_syncnode(self) -> int:
|
||||
"""Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration.
|
||||
|
||||
Assume ``-1`` if it is not set or invalid.
|
||||
"""
|
||||
return self.get_int('maximum_lag_on_syncnode', -1)
|
||||
|
||||
@property
|
||||
def primary_start_timeout(self) -> int:
|
||||
"""Currently configured value of ``primary_start_timeout`` from the global configuration.
|
||||
|
||||
Assume ``300`` if it is not set or invalid.
|
||||
|
||||
.. note::
|
||||
``master_start_timeout`` is still supported to keep backward compatibility.
|
||||
"""
|
||||
default = 300
|
||||
return self.get_int('primary_start_timeout', default)\
|
||||
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
|
||||
|
||||
@property
|
||||
def primary_stop_timeout(self) -> int:
|
||||
"""Currently configured value of ``primary_stop_timeout`` from the global configuration.
|
||||
|
||||
Assume ``0`` if it is not set or invalid.
|
||||
|
||||
.. note::
|
||||
``master_stop_timeout`` is still supported to keep backward compatibility.
|
||||
"""
|
||||
default = 0
|
||||
return self.get_int('primary_stop_timeout', default)\
|
||||
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
|
||||
|
||||
|
||||
def get_global_config(cluster: Optional[Cluster], default: Optional[Dict[str, Any]] = None) -> GlobalConfig:
|
||||
"""Instantiates :class:`GlobalConfig` based on the input.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
:param default: default configuration, which will be used if there is no valid *cluster.config*.
|
||||
|
||||
:returns: :class:`GlobalConfig` object.
|
||||
"""
|
||||
# Try to protect from the case when DCS was wiped out
|
||||
if cluster and cluster.config and cluster.config.modify_version:
|
||||
config = cluster.config.data
|
||||
else:
|
||||
config = default or {}
|
||||
return GlobalConfig(deepcopy(config))
|
||||
|
||||
|
||||
class Config(object):
|
||||
"""
|
||||
"""Handle Patroni configuration.
|
||||
|
||||
This class is responsible for:
|
||||
|
||||
1) Building and giving access to `effective_configuration` from:
|
||||
* `Config.__DEFAULT_CONFIG` -- some sane default values
|
||||
* `dynamic_configuration` -- configuration stored in DCS
|
||||
* `local_configuration` -- configuration from `config.yml` or environment
|
||||
1) Building and giving access to ``effective_configuration`` from:
|
||||
|
||||
2) Saving and loading `dynamic_configuration` into 'patroni.dynamic.json' file
|
||||
* ``Config.__DEFAULT_CONFIG`` -- some sane default values;
|
||||
* ``dynamic_configuration`` -- configuration stored in DCS;
|
||||
* ``local_configuration`` -- configuration from `config.yml` or environment.
|
||||
|
||||
2) Saving and loading ``dynamic_configuration`` into 'patroni.dynamic.json' file
|
||||
located in local_configuration['postgresql']['data_dir'] directory.
|
||||
This is necessary to be able to restore `dynamic_configuration`
|
||||
if DCS was accidentally wiped
|
||||
This is necessary to be able to restore ``dynamic_configuration``
|
||||
if DCS was accidentally wiped.
|
||||
|
||||
3) Loading of configuration file in the old format and converting it into new format
|
||||
3) Loading of configuration file in the old format and converting it into new format.
|
||||
|
||||
4) Mimicking some of the `dict` interfaces to make it possible
|
||||
to work with it as with the old `config` object.
|
||||
4) Mimicking some ``dict`` interfaces to make it possible
|
||||
to work with it as with the old ``config`` object.
|
||||
|
||||
:cvar PATRONI_CONFIG_VARIABLE: name of the environment variable that can be used to load Patroni configuration from.
|
||||
:cvar __CACHE_FILENAME: name of the file used to cache dynamic configuration under Postgres data directory.
|
||||
:cvar __DEFAULT_CONFIG: default configuration values for some Patroni settings.
|
||||
"""
|
||||
|
||||
PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION'
|
||||
|
||||
__CACHE_FILENAME = 'patroni.dynamic.json'
|
||||
__REMAP_KEYS = {
|
||||
'master_start_timeout': 'primary_start_timeout',
|
||||
'master_stop_timeout': 'primary_stop_timeout'
|
||||
}
|
||||
__DEFAULT_CONFIG = {
|
||||
__DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10,
|
||||
'maximum_lag_on_failover': 1048576,
|
||||
'maximum_lag_on_syncnode': -1,
|
||||
'check_timeline': False,
|
||||
'primary_start_timeout': 300,
|
||||
'primary_stop_timeout': 0,
|
||||
'synchronous_mode': False,
|
||||
'synchronous_mode_strict': False,
|
||||
'synchronous_node_count': 1,
|
||||
'failsafe_mode': False,
|
||||
'standby_cluster': {
|
||||
'create_replica_methods': '',
|
||||
'host': '',
|
||||
@@ -84,29 +243,45 @@ class Config(object):
|
||||
'recovery_min_apply_delay': ''
|
||||
},
|
||||
'postgresql': {
|
||||
'bin_dir': '',
|
||||
'use_slots': True,
|
||||
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()
|
||||
if p not in ('wal_keep_segments', 'wal_keep_size')})
|
||||
},
|
||||
'watchdog': {
|
||||
'mode': 'automatic',
|
||||
if v[0] is not None and p not in ('wal_keep_segments', 'wal_keep_size')})
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, configfile, validator=default_validator):
|
||||
self._modify_index = -1
|
||||
def __init__(self, configfile: str,
|
||||
validator: Optional[Callable[[Dict[str, Any]], List[str]]] = default_validator) -> None:
|
||||
"""Create a new instance of :class:`Config` and validate the loaded configuration using *validator*.
|
||||
|
||||
.. note::
|
||||
Patroni will read configuration from these locations in this order:
|
||||
|
||||
* file or directory path passed as command-line argument (*configfile*), if it exists and the file or
|
||||
files found in the directory can be parsed (see :meth:`~Config._load_config_path`), otherwise
|
||||
* YAML file passed via the environment variable (see :attr:`PATRONI_CONFIG_VARIABLE`), if the referenced
|
||||
file exists and can be parsed, otherwise
|
||||
* from configuration values defined as environment variables, see
|
||||
:meth:`~Config._build_environment_configuration`.
|
||||
|
||||
:param configfile: path to Patroni configuration file.
|
||||
:param validator: function used to validate Patroni configuration. It should receive a dictionary which
|
||||
represents Patroni configuration, and return a list of zero or more error messages based on validation.
|
||||
|
||||
:raises:
|
||||
:class:`ConfigParseError`: if any issue is reported by *validator*.
|
||||
"""
|
||||
self._modify_version = -1
|
||||
self._dynamic_configuration = {}
|
||||
|
||||
self.__environment_configuration = self._build_environment_configuration()
|
||||
|
||||
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment
|
||||
self._config_file = configfile and os.path.exists(configfile) and configfile
|
||||
self._config_file = configfile if configfile and os.path.exists(configfile) else None
|
||||
if self._config_file:
|
||||
self._local_configuration = self._load_config_file()
|
||||
else:
|
||||
config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None)
|
||||
self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration
|
||||
|
||||
if validator:
|
||||
errors = validator(self._local_configuration)
|
||||
if errors:
|
||||
@@ -115,24 +290,49 @@ class Config(object):
|
||||
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
|
||||
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
|
||||
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
|
||||
self._load_cache()
|
||||
if validator: # patronictl uses validator=None
|
||||
self._load_cache() # we don't want to load anything from local cache for ctl
|
||||
self._validate_failover_tags() # irrelevant for ctl
|
||||
self._cache_needs_saving = False
|
||||
|
||||
@property
|
||||
def config_file(self):
|
||||
def config_file(self) -> Optional[str]:
|
||||
"""Path to Patroni configuration file, if any, else ``None``."""
|
||||
return self._config_file
|
||||
|
||||
@property
|
||||
def dynamic_configuration(self):
|
||||
def dynamic_configuration(self) -> Dict[str, Any]:
|
||||
"""Deep copy of cached Patroni dynamic configuration."""
|
||||
return deepcopy(self._dynamic_configuration)
|
||||
|
||||
def check_mode(self, mode):
|
||||
return bool(parse_bool(self._dynamic_configuration.get(mode)))
|
||||
@property
|
||||
def local_configuration(self) -> Dict[str, Any]:
|
||||
"""Deep copy of cached Patroni local configuration.
|
||||
|
||||
def _load_config_path(self, path):
|
||||
:returns: copy of :attr:`~Config._local_configuration`
|
||||
"""
|
||||
If path is a file, loads the yml file pointed to by path.
|
||||
If path is a directory, loads all yml files in that directory in alphabetical order
|
||||
return deepcopy(dict(self._local_configuration))
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
"""Deep copy default configuration.
|
||||
|
||||
:returns: copy of :attr:`~Config.__DEFAULT_CONFIG`
|
||||
"""
|
||||
return deepcopy(cls.__DEFAULT_CONFIG)
|
||||
|
||||
def _load_config_path(self, path: str) -> Dict[str, Any]:
|
||||
"""Load Patroni configuration file(s) from *path*.
|
||||
|
||||
If *path* is a file, load the yml file pointed to by *path*.
|
||||
If *path* is a directory, load all yml files in that directory in alphabetical order.
|
||||
|
||||
:param path: path to either an YAML configuration file, or to a folder containing YAML configuration files.
|
||||
|
||||
:returns: configuration after reading the configuration file(s) from *path*.
|
||||
|
||||
:raises:
|
||||
:class:`ConfigParseError`: if *path* is invalid.
|
||||
"""
|
||||
if os.path.isfile(path):
|
||||
files = [path]
|
||||
@@ -143,20 +343,26 @@ class Config(object):
|
||||
logger.error('config path %s is neither directory nor file', path)
|
||||
raise ConfigParseError('invalid config path')
|
||||
|
||||
overall_config = {}
|
||||
overall_config: Dict[str, Any] = {}
|
||||
for fname in files:
|
||||
with open(fname) as f:
|
||||
config = yaml.safe_load(f)
|
||||
patch_config(overall_config, config)
|
||||
return overall_config
|
||||
|
||||
def _load_config_file(self):
|
||||
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
|
||||
config = self._load_config_path(self._config_file)
|
||||
def _load_config_file(self) -> Dict[str, Any]:
|
||||
"""Load configuration file(s) from filesystem and apply values which were set via environment variables.
|
||||
|
||||
:returns: final configuration after merging configuration file(s) and environment variables.
|
||||
"""
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert self.config_file is not None
|
||||
config = self._load_config_path(self.config_file)
|
||||
patch_config(config, self.__environment_configuration)
|
||||
return config
|
||||
|
||||
def _load_cache(self):
|
||||
def _load_cache(self) -> None:
|
||||
"""Load dynamic configuration from ``patroni.dynamic.json``."""
|
||||
if os.path.isfile(self._cache_file):
|
||||
try:
|
||||
with open(self._cache_file) as f:
|
||||
@@ -164,15 +370,23 @@ class Config(object):
|
||||
except Exception:
|
||||
logger.exception('Exception when loading file: %s', self._cache_file)
|
||||
|
||||
def save_cache(self):
|
||||
def save_cache(self) -> None:
|
||||
"""Save dynamic configuration to ``patroni.dynamic.json`` under Postgres data directory.
|
||||
|
||||
.. note::
|
||||
``patroni.dynamic.jsonXXXXXX`` is created as a temporary file and than renamed to ``patroni.dynamic.json``,
|
||||
where ``XXXXXX`` is a random suffix.
|
||||
"""
|
||||
if self._cache_needs_saving:
|
||||
tmpfile = fd = None
|
||||
try:
|
||||
pg_perm.set_permissions_from_data_directory(self._data_dir)
|
||||
(fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir)
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
fd = None
|
||||
json.dump(self.dynamic_configuration, f)
|
||||
tmpfile = shutil.move(tmpfile, self._cache_file)
|
||||
os.chmod(self._cache_file, pg_perm.file_create_mode)
|
||||
self._cache_needs_saving = False
|
||||
except Exception:
|
||||
logger.exception('Exception when saving file: %s', self._cache_file)
|
||||
@@ -187,16 +401,84 @@ class Config(object):
|
||||
except Exception:
|
||||
logger.error('Can not remove temporary file %s', tmpfile)
|
||||
|
||||
def __get_and_maybe_adjust_int_value(self, config: Dict[str, Any], param: str, min_value: int) -> int:
|
||||
"""Get, validate and maybe adjust a *param* integer value from the *config* :class:`dict`.
|
||||
|
||||
.. note:
|
||||
If the value is smaller than provided *min_value* we update the *config*.
|
||||
|
||||
This method may raise an exception if value isn't :class:`int` or cannot be casted to :class:`int`.
|
||||
|
||||
:param config: :class:`dict` object with new global configuration.
|
||||
:param param: name of the configuration parameter we want to read/validate/adjust.
|
||||
:param min_value: the minimum possible value that a given *param* could have.
|
||||
|
||||
:returns: an integer value which corresponds to a provided *param*.
|
||||
"""
|
||||
value = int(config.get(param, self.__DEFAULT_CONFIG[param]))
|
||||
if value < min_value:
|
||||
logger.warning("%s=%d can't be smaller than %d, adjusting...", param, value, min_value)
|
||||
value = config[param] = min_value
|
||||
return value
|
||||
|
||||
def _validate_and_adjust_timeouts(self, config: Dict[str, Any]) -> None:
|
||||
"""Validate and adjust ``loop_wait``, ``retry_timeout``, and ``ttl`` values if necessary.
|
||||
|
||||
Minimum values:
|
||||
|
||||
* ``loop_wait``: 1 second;
|
||||
* ``retry_timeout``: 3 seconds.
|
||||
* ``ttl``: 20 seconds;
|
||||
|
||||
Maximum values:
|
||||
In case if values don't fulfill the following rule, ``retry_timeout`` and ``loop_wait``
|
||||
are reduced so that the rule is fulfilled:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
loop_wait + 2 * retry_timeout <= ttl
|
||||
|
||||
.. note:
|
||||
We prefer to reduce ``loop_wait`` and will reduce ``retry_timeout`` only if ``loop_wait``
|
||||
is already set to a minimal possible value.
|
||||
|
||||
:param config: :class:`dict` object with new global configuration.
|
||||
"""
|
||||
|
||||
min_loop_wait = 1
|
||||
loop_wait = self. __get_and_maybe_adjust_int_value(config, 'loop_wait', min_loop_wait)
|
||||
retry_timeout = self. __get_and_maybe_adjust_int_value(config, 'retry_timeout', 3)
|
||||
ttl = self. __get_and_maybe_adjust_int_value(config, 'ttl', 20)
|
||||
|
||||
if min_loop_wait + 2 * retry_timeout > ttl:
|
||||
config['loop_wait'] = min_loop_wait
|
||||
config['retry_timeout'] = (ttl - min_loop_wait) // 2
|
||||
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. '
|
||||
'Adjusting loop_wait from %d to %d and retry_timeout from %d to %d',
|
||||
ttl, loop_wait, min_loop_wait, retry_timeout, config['retry_timeout'])
|
||||
elif loop_wait + 2 * retry_timeout > ttl:
|
||||
config['loop_wait'] = ttl - 2 * retry_timeout
|
||||
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d and retry_timeout=%d.'
|
||||
' Adjusting loop_wait from %d to %d', ttl, retry_timeout, loop_wait, config['loop_wait'])
|
||||
|
||||
# configuration could be either ClusterConfig or dict
|
||||
def set_dynamic_configuration(self, configuration):
|
||||
def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool:
|
||||
"""Set dynamic configuration values with given *configuration*.
|
||||
|
||||
:param configuration: new dynamic configuration values. Supports :class:`dict` for backward compatibility.
|
||||
|
||||
:returns: ``True`` if changes have been detected between current dynamic configuration and the new dynamic
|
||||
*configuration*, ``False`` otherwise.
|
||||
"""
|
||||
if isinstance(configuration, ClusterConfig):
|
||||
if self._modify_index == configuration.modify_index:
|
||||
return False # If the index didn't changed there is nothing to do
|
||||
self._modify_index = configuration.modify_index
|
||||
if self._modify_version == configuration.modify_version:
|
||||
return False # If the version didn't change there is nothing to do
|
||||
self._modify_version = configuration.modify_version
|
||||
configuration = configuration.data
|
||||
|
||||
if not deep_compare(self._dynamic_configuration, configuration):
|
||||
try:
|
||||
self._validate_and_adjust_timeouts(configuration)
|
||||
self.__effective_configuration = self._build_effective_configuration(configuration,
|
||||
self._local_configuration)
|
||||
self._dynamic_configuration = configuration
|
||||
@@ -204,8 +486,17 @@ class Config(object):
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Exception when setting dynamic_configuration')
|
||||
return False
|
||||
|
||||
def reload_local_configuration(self):
|
||||
def reload_local_configuration(self) -> Optional[bool]:
|
||||
"""Reload configuration values from the configuration file(s).
|
||||
|
||||
.. note::
|
||||
Designed to be used when user applies changes to configuration file(s), so Patroni can use the new values
|
||||
with a reload instead of a restart.
|
||||
|
||||
:returns: ``True`` if changes have been detected between current local configuration
|
||||
"""
|
||||
if self.config_file:
|
||||
try:
|
||||
configuration = self._load_config_file()
|
||||
@@ -213,6 +504,7 @@ class Config(object):
|
||||
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
|
||||
self._local_configuration = configuration
|
||||
self.__effective_configuration = new_configuration
|
||||
self._validate_failover_tags()
|
||||
return True
|
||||
else:
|
||||
logger.info('No local configuration items changed.')
|
||||
@@ -220,18 +512,85 @@ class Config(object):
|
||||
logger.exception('Exception when reloading local configuration from %s', self.config_file)
|
||||
|
||||
@staticmethod
|
||||
def _process_postgresql_parameters(parameters, is_local=False):
|
||||
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)}
|
||||
def _process_postgresql_parameters(parameters: Dict[str, Any], is_local: bool = False) -> Dict[str, Any]:
|
||||
"""Process Postgres *parameters*.
|
||||
|
||||
def _safe_copy_dynamic_configuration(self, dynamic_configuration):
|
||||
config = deepcopy(self.__DEFAULT_CONFIG)
|
||||
.. note::
|
||||
If *is_local* configuration discard any setting from *parameters* that is listed under
|
||||
:attr:`~patroni.postgresql.config.ConfigHandler.CMDLINE_OPTIONS` as those are supposed to be set only
|
||||
through dynamic configuration.
|
||||
|
||||
When setting parameters from :attr:`~patroni.postgresql.config.ConfigHandler.CMDLINE_OPTIONS` through
|
||||
dynamic configuration their value will be validated as per the validator defined in that very same
|
||||
attribute entry. If the given value cannot be validated, a warning will be logged and the default value of
|
||||
the GUC will be used instead.
|
||||
|
||||
Some parameters from :attr:`~patroni.postgresql.config.ConfigHandler.CMDLINE_OPTIONS` cannot be set even if
|
||||
not *is_local* configuration:
|
||||
|
||||
* ``listen_addresses``: inferred from ``postgresql.listen`` local configuration or from
|
||||
``PATRONI_POSTGRESQL_LISTEN`` environment variable;
|
||||
* ``port``: inferred from ``postgresql.listen`` local configuration or from
|
||||
``PATRONI_POSTGRESQL_LISTEN`` environment variable;
|
||||
* ``cluster_name``: set through ``scope`` local configuration or through ``PATRONI_SCOPE`` environment
|
||||
variable;
|
||||
* ``hot_standby``: always enabled;
|
||||
* ``wal_log_hints``: always enabled.
|
||||
|
||||
:param parameters: Postgres parameters to be processed. Should be the parsed YAML value of
|
||||
``postgresql.parameters`` configuration, either from local or from dynamic configuration.
|
||||
|
||||
:param is_local: should be ``True`` if *parameters* refers to local configuration, or ``False`` if *parameters*
|
||||
refers to dynamic configuration.
|
||||
|
||||
:returns: new value for ``postgresql.parameters`` after processing and validating *parameters*.
|
||||
"""
|
||||
pg_params: Dict[str, Any] = {}
|
||||
|
||||
for name, value in (parameters or {}).items():
|
||||
if name not in ConfigHandler.CMDLINE_OPTIONS:
|
||||
pg_params[name] = value
|
||||
elif not is_local:
|
||||
validator = ConfigHandler.CMDLINE_OPTIONS[name][1]
|
||||
if validator(value):
|
||||
int_val = parse_int(value) if isinstance(validator, IntValidator) else None
|
||||
pg_params[name] = int_val if isinstance(int_val, int) else value
|
||||
else:
|
||||
logger.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
|
||||
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
|
||||
|
||||
return pg_params
|
||||
|
||||
def _safe_copy_dynamic_configuration(self, dynamic_configuration: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a copy of *dynamic_configuration*.
|
||||
|
||||
Merge *dynamic_configuration* with :attr:`__DEFAULT_CONFIG` (*dynamic_configuration* takes precedence), and
|
||||
process ``postgresql.parameters`` from *dynamic_configuration* through :func:`_process_postgresql_parameters`,
|
||||
if present.
|
||||
|
||||
.. note::
|
||||
The following settings are not allowed in ``postgresql`` section as they are intended to be local
|
||||
configuration, and are removed if present:
|
||||
|
||||
* ``connect_address``;
|
||||
* ``proxy_address``;
|
||||
* ``listen``;
|
||||
* ``config_dir``;
|
||||
* ``data_dir``;
|
||||
* ``pgpass``;
|
||||
* ``authentication``;
|
||||
|
||||
Besides that any setting present in *dynamic_configuration* but absent from :attr:`__DEFAULT_CONFIG` is
|
||||
discarded.
|
||||
|
||||
:param dynamic_configuration: Patroni dynamic configuration.
|
||||
|
||||
:returns: copy of *dynamic_configuration*, merged with default dynamic configuration and with some sanity checks
|
||||
performed over it.
|
||||
"""
|
||||
config = self.get_default_config()
|
||||
|
||||
for name, value in dynamic_configuration.items():
|
||||
# allow copying master_start_timeout->primary_start_timeout when the latter isn't in dynamic_configuration
|
||||
if name in self.__REMAP_KEYS and self.__REMAP_KEYS[name] not in dynamic_configuration:
|
||||
name = self.__REMAP_KEYS[name]
|
||||
if name == 'postgresql':
|
||||
for name, value in (value or {}).items():
|
||||
if name == 'parameters':
|
||||
@@ -244,17 +603,30 @@ class Config(object):
|
||||
if name in self.__DEFAULT_CONFIG['standby_cluster']:
|
||||
config['standby_cluster'][name] = deepcopy(value)
|
||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
|
||||
if name in ('synchronous_mode', 'synchronous_mode_strict', 'failsafe_mode'):
|
||||
config[name] = value
|
||||
else:
|
||||
config[name] = int(value)
|
||||
config[name] = int(value)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _build_environment_configuration():
|
||||
ret = defaultdict(dict)
|
||||
def _build_environment_configuration() -> Dict[str, Any]:
|
||||
"""Get local configuration settings that were specified through environment variables.
|
||||
|
||||
def _popenv(name):
|
||||
:returns: dictionary containing the found environment variables and their values, respecting the expected
|
||||
structure of Patroni configuration.
|
||||
"""
|
||||
ret: Dict[str, Any] = defaultdict(dict)
|
||||
|
||||
def _popenv(name: str) -> Optional[str]:
|
||||
"""Get value of environment variable *name*.
|
||||
|
||||
.. note::
|
||||
*name* is prefixed with :data:`~patroni.PATRONI_ENV_PREFIX` when searching in the environment.
|
||||
|
||||
Also, the corresponding environment variable is removed from the environment upon reading its value.
|
||||
|
||||
:param name: name of the environment variable.
|
||||
|
||||
:returns: value of *name*, if present in the environment, otherwise ``None``.
|
||||
"""
|
||||
return os.environ.pop(PATRONI_ENV_PREFIX + name.upper(), None)
|
||||
|
||||
for param in ('name', 'namespace', 'scope'):
|
||||
@@ -262,7 +634,24 @@ class Config(object):
|
||||
if value:
|
||||
ret[param] = value
|
||||
|
||||
def _fix_log_env(name, oldname):
|
||||
def _fix_log_env(name: str, oldname: str) -> None:
|
||||
"""Normalize a log related environment variable.
|
||||
|
||||
.. note::
|
||||
Patroni used to support different names for log related environment variables in the past. As the
|
||||
environment variables were renamed, this function takes care of mapping and normalizing the environment.
|
||||
|
||||
*name* is prefixed with :data:`~patroni.PATRONI_ENV_PREFIX` and ``LOG`` when searching in the
|
||||
environment.
|
||||
|
||||
*oldname* is prefixed with :data:`~patroni.PATRONI_ENV_PREFIX` when searching in the environment.
|
||||
|
||||
If both *name* and *oldname* are set in the environment, *name* takes precedence.
|
||||
|
||||
:param name: new name of a log related environment variable.
|
||||
:param oldname: original name of a log related environment variable.
|
||||
:type oldname: str
|
||||
"""
|
||||
value = _popenv(oldname)
|
||||
name = PATRONI_ENV_PREFIX + 'LOG_' + name.upper()
|
||||
if value and name not in os.environ:
|
||||
@@ -271,7 +660,16 @@ class Config(object):
|
||||
for name, oldname in (('level', 'loglevel'), ('format', 'logformat'), ('dateformat', 'log_datefmt')):
|
||||
_fix_log_env(name, oldname)
|
||||
|
||||
def _set_section_values(section, params):
|
||||
def _set_section_values(section: str, params: List[str]) -> None:
|
||||
"""Get value of *params* environment variables that are related with *section*.
|
||||
|
||||
.. note::
|
||||
The values are retrieved from the environment and updated directly into the returning dictionary of
|
||||
:func:`_build_environment_configuration`.
|
||||
|
||||
:param section: configuration section the *params* belong to.
|
||||
:param params: name of the Patroni settings.
|
||||
"""
|
||||
for param in params:
|
||||
value = _popenv(section + '_' + param)
|
||||
if value:
|
||||
@@ -279,7 +677,8 @@ class Config(object):
|
||||
|
||||
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
|
||||
'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
|
||||
'https_extra_headers', 'allowlist', 'allowlist_include_members'])
|
||||
'https_extra_headers', 'allowlist', 'allowlist_include_members',
|
||||
'request_queue_size'])
|
||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
|
||||
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
||||
@@ -287,6 +686,12 @@ class Config(object):
|
||||
'dir', 'file_size', 'file_num', 'loggers'])
|
||||
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
|
||||
|
||||
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
|
||||
value = _popenv('POSTGRESQL_BIN_' + binary)
|
||||
if value:
|
||||
ret['postgresql'].setdefault('bin_name', {})[binary] = value
|
||||
|
||||
# parse all values retrieved from the environment as Python objects, according to the expected type
|
||||
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
@@ -294,14 +699,22 @@ class Config(object):
|
||||
if value is not None:
|
||||
ret[first][second] = value
|
||||
|
||||
for second in ('max_queue_size', 'file_size', 'file_num'):
|
||||
value = ret.get('log', {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_int(value)
|
||||
if value is not None:
|
||||
ret['log'][second] = value
|
||||
for first, params in (('restapi', ('request_queue_size',)),
|
||||
('log', ('max_queue_size', 'file_size', 'file_num'))):
|
||||
for second in params:
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_int(value)
|
||||
if value is not None:
|
||||
ret[first][second] = value
|
||||
|
||||
def _parse_list(value):
|
||||
def _parse_list(value: str) -> Optional[List[str]]:
|
||||
"""Parse an YAML list *value* as a :class:`list`.
|
||||
|
||||
:param value: YAML list as a string.
|
||||
|
||||
:returns: *value* as :class:`list`.
|
||||
"""
|
||||
if not (value.strip().startswith('-') or '[' in value):
|
||||
value = '[{0}]'.format(value)
|
||||
try:
|
||||
@@ -317,7 +730,13 @@ class Config(object):
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
def _parse_dict(value):
|
||||
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse an YAML dictionary *value* as a :class:`dict`.
|
||||
|
||||
:param value: YAML dictionary as a string.
|
||||
|
||||
:returns: *value* as :class:`dict`.
|
||||
"""
|
||||
if not value.strip().startswith('{'):
|
||||
value = '{{{0}}}'.format(value)
|
||||
try:
|
||||
@@ -334,17 +753,25 @@ class Config(object):
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
def _get_auth(name, params=None):
|
||||
ret = {}
|
||||
for param in params or _AUTH_ALLOWED_PARAMETERS[:2]:
|
||||
def _get_auth(name: str, params: Collection[str] = _AUTH_ALLOWED_PARAMETERS[:2]) -> Dict[str, str]:
|
||||
"""Get authorization related environment variables *params* from section *name*.
|
||||
|
||||
:param name: name of a configuration section that may contain authorization *params*.
|
||||
:param params: the authorization settings that may be set under section *name*.
|
||||
|
||||
:returns: dictionary containing environment values for authorization *params* of section *name*.
|
||||
"""
|
||||
ret: Dict[str, str] = {}
|
||||
for param in params:
|
||||
value = _popenv(name + '_' + param)
|
||||
if value:
|
||||
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'):
|
||||
@@ -358,13 +785,14 @@ class Config(object):
|
||||
for param in list(os.environ.keys()):
|
||||
if param.startswith(PATRONI_ENV_PREFIX):
|
||||
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
|
||||
name, suffix = (param[8:].split('_', 1) + [''])[:2]
|
||||
name, suffix = (param[len(PATRONI_ENV_PREFIX):].split('_', 1) + [''])[:2]
|
||||
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'SRV_SUFFIX', 'URL', 'PROXY',
|
||||
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
|
||||
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'SERVICE_CHECK_TLS_SERVER_NAME',
|
||||
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
|
||||
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL', 'SET_ACLS',
|
||||
'GROUP', 'DATABASE') and name:
|
||||
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
|
||||
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
|
||||
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
|
||||
value = os.environ.pop(param)
|
||||
if name == 'CITUS':
|
||||
if suffix == 'GROUP':
|
||||
@@ -373,7 +801,7 @@ class Config(object):
|
||||
continue
|
||||
elif suffix == 'PORT':
|
||||
value = value and parse_int(value)
|
||||
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS'):
|
||||
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
|
||||
value = value and _parse_list(value)
|
||||
elif suffix in ('LABELS', 'SET_ACLS'):
|
||||
value = _parse_dict(value)
|
||||
@@ -388,14 +816,14 @@ class Config(object):
|
||||
users = {}
|
||||
for param in list(os.environ.keys()):
|
||||
if param.startswith(PATRONI_ENV_PREFIX):
|
||||
name, suffix = (param[8:].rsplit('_', 1) + [''])[:2]
|
||||
name, suffix = (param[len(PATRONI_ENV_PREFIX):].rsplit('_', 1) + [''])[:2]
|
||||
# PATRONI_<username>_PASSWORD=<password>, PATRONI_<username>_OPTIONS=<option1,option2,...>
|
||||
# CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>'
|
||||
if name and suffix == 'PASSWORD':
|
||||
password = os.environ.pop(param)
|
||||
if password:
|
||||
users[name] = {'password': password}
|
||||
options = os.environ.pop(param[:-9] + '_OPTIONS', None)
|
||||
options = os.environ.pop(param[:-9] + '_OPTIONS', None) # replace "_PASSWORD" with "_OPTIONS"
|
||||
options = options and _parse_list(options)
|
||||
if options:
|
||||
users[name]['options'] = options
|
||||
@@ -404,12 +832,23 @@ class Config(object):
|
||||
|
||||
return ret
|
||||
|
||||
def _build_effective_configuration(self, dynamic_configuration, local_configuration):
|
||||
def _build_effective_configuration(self, dynamic_configuration: Dict[str, Any],
|
||||
local_configuration: Dict[str, Union[Dict[str, Any], Any]]) -> Dict[str, Any]:
|
||||
"""Build effective configuration by merging *dynamic_configuration* and *local_configuration*.
|
||||
|
||||
.. note::
|
||||
*local_configuration* takes precedence over *dynamic_configuration* if a setting is defined in both.
|
||||
|
||||
:param dynamic_configuration: Patroni dynamic configuration.
|
||||
:param local_configuration: Patroni local configuration.
|
||||
|
||||
:returns: _description_
|
||||
"""
|
||||
config = self._safe_copy_dynamic_configuration(dynamic_configuration)
|
||||
for name, value in local_configuration.items():
|
||||
if name == 'citus': # remove invalid citus configuration
|
||||
if isinstance(value, dict) and isinstance(value.get('group'), six.integer_types)\
|
||||
and isinstance(value.get('database'), six.string_types):
|
||||
if isinstance(value, dict) and isinstance(value.get('group'), int)\
|
||||
and isinstance(value.get('database'), str):
|
||||
config[name] = value
|
||||
elif name == 'postgresql':
|
||||
for name, value in (value or {}).items():
|
||||
@@ -420,9 +859,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
|
||||
|
||||
@@ -459,10 +899,6 @@ class Config(object):
|
||||
'name',
|
||||
'scope',
|
||||
'retry_timeout',
|
||||
'synchronous_mode',
|
||||
'synchronous_mode_strict',
|
||||
'synchronous_node_count',
|
||||
'maximum_lag_on_syncnode',
|
||||
'citus'
|
||||
)
|
||||
|
||||
@@ -470,14 +906,81 @@ class Config(object):
|
||||
|
||||
return config
|
||||
|
||||
def get(self, key, default=None):
|
||||
def get(self, key: str, default: Optional[Any] = None) -> Any:
|
||||
"""Get effective value of ``key`` setting from Patroni configuration root.
|
||||
|
||||
Designed to work the same way as :func:`dict.get`.
|
||||
|
||||
:param key: name of the setting.
|
||||
:param default: default value if *key* is not present in the effective configuration.
|
||||
|
||||
:returns: value of *key*, if present in the effective configuration, otherwise *default*.
|
||||
"""
|
||||
return self.__effective_configuration.get(key, default)
|
||||
|
||||
def __contains__(self, key):
|
||||
def __contains__(self, key: str) -> bool:
|
||||
"""Check if setting *key* is present in the effective configuration.
|
||||
|
||||
Designed to work the same way as :func:`dict.__contains__`.
|
||||
|
||||
:param key: name of the setting to be checked.
|
||||
|
||||
:returns: ``True`` if setting *key* exists in effective configuration, else ``False``.
|
||||
"""
|
||||
return key in self.__effective_configuration
|
||||
|
||||
def __getitem__(self, key):
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get value of setting *key* from effective configuration.
|
||||
|
||||
Designed to work the same way as :func:`dict.__getitem__`.
|
||||
|
||||
:param key: name of the setting.
|
||||
|
||||
:returns: value of setting *key*.
|
||||
|
||||
:raises:
|
||||
:class:`KeyError`: if *key* is not present in effective configuration.
|
||||
"""
|
||||
return self.__effective_configuration[key]
|
||||
|
||||
def copy(self):
|
||||
def copy(self) -> Dict[str, Any]:
|
||||
"""Get a deep copy of effective Patroni configuration.
|
||||
|
||||
:returns: a deep copy of the Patroni configuration.
|
||||
"""
|
||||
return deepcopy(self.__effective_configuration)
|
||||
|
||||
def get_global_config(self, cluster: Optional[Cluster]) -> GlobalConfig:
|
||||
"""Instantiate :class:`GlobalConfig` based on input.
|
||||
|
||||
Use the configuration from provided *cluster* (the most up-to-date) or from the
|
||||
local cache if *cluster.config* is not initialized or doesn't have a valid config.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
|
||||
:returns: :class:`GlobalConfig` object.
|
||||
"""
|
||||
return get_global_config(cluster, self._dynamic_configuration)
|
||||
|
||||
def _validate_failover_tags(self) -> None:
|
||||
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
|
||||
|
||||
.. note::
|
||||
To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory
|
||||
configuration is one where ``nofailover`` is ``True`` but ``failover_priority > 0``, or where
|
||||
``nofailover`` is ``False``, but ``failover_priority <= 0``. Essentially, ``nofailover`` and
|
||||
``failover_priority`` are communicating different things.
|
||||
This checks for this edge case (which is a misconfiguration on the part of the user) and warns them.
|
||||
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
|
||||
bedrock source of truth)
|
||||
"""
|
||||
tags = self.get('tags', {})
|
||||
if 'nofailover' not in tags:
|
||||
return
|
||||
nofailover_tag = tags.get('nofailover')
|
||||
failover_priority_tag = parse_int(tags.get('failover_priority'))
|
||||
if failover_priority_tag is not None \
|
||||
and (bool(nofailover_tag) is True and failover_priority_tag > 0
|
||||
or bool(nofailover_tag) is False and failover_priority_tag <= 0):
|
||||
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
|
||||
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""patroni ``--generate-config`` machinery."""
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
import socket
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
from getpass import getuser, getpass
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Cursor
|
||||
from psycopg2 import cursor
|
||||
|
||||
from . import psycopg
|
||||
from .config import Config
|
||||
from .exceptions import PatroniException
|
||||
from .log import PatroniLogger
|
||||
from .postgresql.config import ConfigHandler, parse_dsn
|
||||
from .postgresql.misc import postgres_major_version_to_int
|
||||
from .utils import get_major_version, parse_bool, patch_config, read_stripped
|
||||
|
||||
|
||||
# Mapping between the libpq connection parameters and the environment variables.
|
||||
# This dict should be kept in sync with `patroni.utils._AUTH_ALLOWED_PARAMETERS`
|
||||
# (we use "username" in the Patroni config for some reason, other parameter names are the same).
|
||||
_AUTH_ALLOWED_PARAMETERS_MAPPING = {
|
||||
'user': 'PGUSER',
|
||||
'password': 'PGPASSWORD',
|
||||
'sslmode': 'PGSSLMODE',
|
||||
'sslcert': 'PGSSLCERT',
|
||||
'sslkey': 'PGSSLKEY',
|
||||
'sslpassword': '',
|
||||
'sslrootcert': 'PGSSLROOTCERT',
|
||||
'sslcrl': 'PGSSLCRL',
|
||||
'sslcrldir': 'PGSSLCRLDIR',
|
||||
'gssencmode': 'PGGSSENCMODE',
|
||||
'channel_binding': 'PGCHANNELBINDING'
|
||||
}
|
||||
NO_VALUE_MSG = '#FIXME'
|
||||
|
||||
|
||||
def get_address() -> Tuple[str, str]:
|
||||
"""Try to get hostname and the ip address for it returned by :func:`~socket.gethostname`.
|
||||
|
||||
.. note::
|
||||
Can also return local ip.
|
||||
|
||||
:returns: tuple consisting of the hostname returned by :func:`~socket.gethostname`
|
||||
and the first element in the sorted list of the addresses returned by :func:`~socket.getaddrinfo`.
|
||||
Sorting guarantees it will prefer IPv4.
|
||||
If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator.NO_VALUE_MSG`.
|
||||
"""
|
||||
hostname = None
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
return hostname, sorted(socket.getaddrinfo(hostname, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0),
|
||||
key=lambda x: x[0])[0][4][0]
|
||||
except Exception as err:
|
||||
logging.warning('Failed to obtain address: %r', err)
|
||||
return NO_VALUE_MSG, NO_VALUE_MSG
|
||||
|
||||
|
||||
class AbstractConfigGenerator(abc.ABC):
|
||||
"""Object representing the generated Patroni config.
|
||||
|
||||
:ivar output_file: full path to the output file to be used.
|
||||
:ivar pg_major: integer representation of the major PostgreSQL version.
|
||||
:ivar config: dictionary used for the generated configuration storage.
|
||||
"""
|
||||
|
||||
_HOSTNAME, _IP = get_address()
|
||||
|
||||
def __init__(self, output_file: Optional[str]) -> None:
|
||||
"""Set up the output file (if passed), helper vars and the minimal config structure.
|
||||
|
||||
:param output_file: full path to the output file to be used.
|
||||
"""
|
||||
self.output_file = output_file
|
||||
self.pg_major = 0
|
||||
self.config = self.get_template_config()
|
||||
|
||||
self.generate()
|
||||
|
||||
@classmethod
|
||||
def get_template_config(cls) -> Dict[str, Any]:
|
||||
"""Generate a template config for further extension (e.g. in the inherited classes).
|
||||
|
||||
:returns: dictionary with the values gathered from Patroni env, hopefully defined hostname and ip address
|
||||
(otherwise set to :data:`~patroni.config_generator.NO_VALUE_MSG`), and some sane defaults.
|
||||
"""
|
||||
template_config: Dict[str, Any] = {
|
||||
'scope': NO_VALUE_MSG,
|
||||
'name': cls._HOSTNAME,
|
||||
'restapi': {
|
||||
'connect_address': cls._IP + ':8008',
|
||||
'listen': cls._IP + ':8008'
|
||||
},
|
||||
'log': {
|
||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
|
||||
},
|
||||
'postgresql': {
|
||||
'data_dir': NO_VALUE_MSG,
|
||||
'connect_address': cls._IP + ':5432',
|
||||
'listen': cls._IP + ':5432',
|
||||
'bin_dir': '',
|
||||
'authentication': {
|
||||
'superuser': {
|
||||
'username': 'postgres',
|
||||
'password': NO_VALUE_MSG
|
||||
},
|
||||
'replication': {
|
||||
'username': 'replicator',
|
||||
'password': NO_VALUE_MSG
|
||||
}
|
||||
}
|
||||
},
|
||||
'tags': {
|
||||
'failover_priority': 1,
|
||||
'noloadbalance': False,
|
||||
'clonefrom': True,
|
||||
'nosync': False,
|
||||
}
|
||||
}
|
||||
|
||||
dynamic_config = Config.get_default_config()
|
||||
# to properly dump CaseInsensitiveDict as YAML later
|
||||
dynamic_config['postgresql']['parameters'] = dict(dynamic_config['postgresql']['parameters'])
|
||||
config = Config('', None).local_configuration # Get values from env
|
||||
config.setdefault('bootstrap', {})['dcs'] = dynamic_config
|
||||
config.setdefault('postgresql', {})
|
||||
del config['bootstrap']['dcs']['standby_cluster']
|
||||
|
||||
patch_config(template_config, config)
|
||||
return template_config
|
||||
|
||||
@abc.abstractmethod
|
||||
def generate(self) -> None:
|
||||
"""Generate config and store in :attr:`~AbstractConfigGenerator.config`."""
|
||||
|
||||
@staticmethod
|
||||
def _format_block(block: Any, line_prefix: str = '') -> str:
|
||||
"""Format a single YAML block.
|
||||
|
||||
.. note::
|
||||
Optionally the formatted block could be indented with the *line_prefix*
|
||||
|
||||
:param block: the object that should be formatted to YAML.
|
||||
:param line_prefix: is used for indentation.
|
||||
|
||||
:returns: a formatted and indented *block*.
|
||||
"""
|
||||
return line_prefix + yaml.safe_dump(block, default_flow_style=False, line_break='\n',
|
||||
allow_unicode=True, indent=2).strip().replace('\n', '\n' + line_prefix)
|
||||
|
||||
def _format_config_section(self, section_name: str) -> Iterator[str]:
|
||||
"""Format and yield as single section of the current :attr:`~AbstractConfigGenerator.config`.
|
||||
|
||||
.. note::
|
||||
If the section is a :class:`dict` object we put an empty line before it.
|
||||
|
||||
:param section_name: a section name in the :attr:`~AbstractConfigGenerator.config`.
|
||||
|
||||
:yields: a formatted section in case if it exists in the :attr:`~AbstractConfigGenerator.config`.
|
||||
"""
|
||||
if section_name in self.config:
|
||||
if isinstance(self.config[section_name], dict):
|
||||
yield ''
|
||||
yield self._format_block({section_name: self.config[section_name]})
|
||||
|
||||
def _format_config(self) -> Iterator[str]:
|
||||
"""Format current :attr:`~AbstractConfigGenerator.config` and enrich it with some comments.
|
||||
|
||||
:yields: formatted lines or blocks that represent a text output of the YAML document.
|
||||
"""
|
||||
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
|
||||
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
|
||||
yield from self._format_config_section(name)
|
||||
|
||||
if 'bootstrap' in self.config:
|
||||
yield '\n# The bootstrap configuration. Works only when the cluster is not yet initialized.'
|
||||
yield '# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!'
|
||||
yield 'bootstrap:'
|
||||
if 'dcs' in self.config['bootstrap']:
|
||||
yield ' # This section will be written into <dcs>:/<namespace>/<scope>/config after initializing'
|
||||
yield ' # new cluster and all other cluster members will use it as a `global configuration`.'
|
||||
yield ' # WARNING! If you want to change any of the parameters that were set up'
|
||||
yield ' # via `bootstrap.dcs` section, please use `patronictl edit-config`!'
|
||||
yield ' dcs:'
|
||||
for name in ('loop_wait', 'retry_timeout', 'ttl'):
|
||||
if name in self.config['bootstrap']['dcs']:
|
||||
yield self._format_block({name: self.config['bootstrap']['dcs'].pop(name)}, ' ')
|
||||
|
||||
for name, value in self.config['bootstrap']['dcs'].items():
|
||||
yield self._format_block({name: value}, ' ')
|
||||
|
||||
for name in ('postgresql', 'watchdog', 'tags'):
|
||||
yield from self._format_config_section(name)
|
||||
|
||||
def _write_config_to_fd(self, fd: TextIO) -> None:
|
||||
"""Format and write current :attr:`~AbstractConfigGenerator.config` to provided file descriptor.
|
||||
|
||||
:param fd: where to write the config file. Could be ``sys.stdout`` or the real file.
|
||||
"""
|
||||
fd.write('\n'.join(self._format_config()))
|
||||
|
||||
def write_config(self) -> None:
|
||||
"""Write current :attr:`~AbstractConfigGenerator.config` to the output file if provided, to stdout otherwise."""
|
||||
if self.output_file:
|
||||
dir_path = os.path.dirname(self.output_file)
|
||||
if dir_path and not os.path.isdir(dir_path):
|
||||
os.makedirs(dir_path)
|
||||
with open(self.output_file, 'w', encoding='UTF-8') as output_file:
|
||||
self._write_config_to_fd(output_file)
|
||||
else:
|
||||
self._write_config_to_fd(sys.stdout)
|
||||
|
||||
|
||||
class SampleConfigGenerator(AbstractConfigGenerator):
|
||||
"""Object representing the generated sample Patroni config.
|
||||
|
||||
Sane defults are used based on the gathered PG version.
|
||||
"""
|
||||
|
||||
@property
|
||||
def get_auth_method(self) -> str:
|
||||
"""Return the preferred authentication method for a specific PG version if provided or the default ``md5``.
|
||||
|
||||
:returns: :class:`str` value for the preferred authentication method.
|
||||
"""
|
||||
return 'scram-sha-256' if self.pg_major and self.pg_major >= 100000 else 'md5'
|
||||
|
||||
def _get_int_major_version(self) -> int:
|
||||
"""Get major PostgreSQL version from the binary as an integer.
|
||||
|
||||
:returns: an integer PostgreSQL major version representation gathered from the PostgreSQL binary.
|
||||
See :func:`~patroni.postgresql.misc.postgres_major_version_to_int` and
|
||||
:func:`~patroni.utils.get_major_version`.
|
||||
"""
|
||||
postgres_bin = ((self.config.get('postgresql') or {}).get('bin_name') or {}).get('postgres', 'postgres')
|
||||
return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin))
|
||||
|
||||
def generate(self) -> None:
|
||||
"""Generate sample config using some sane defaults and update :attr:`~AbstractConfigGenerator.config`."""
|
||||
self.pg_major = self._get_int_major_version()
|
||||
|
||||
self.config['postgresql']['parameters'] = {'password_encryption': self.get_auth_method}
|
||||
username = self.config["postgresql"]["authentication"]["replication"]["username"]
|
||||
self.config['postgresql']['pg_hba'] = [
|
||||
f'host all all all {self.get_auth_method}',
|
||||
f'host replication {username} all {self.get_auth_method}'
|
||||
]
|
||||
|
||||
# add version-specific configuration
|
||||
wal_keep_param = 'wal_keep_segments' if self.pg_major < 130000 else 'wal_keep_size'
|
||||
self.config['bootstrap']['dcs']['postgresql']['parameters'][wal_keep_param] = \
|
||||
ConfigHandler.CMDLINE_OPTIONS[wal_keep_param][0]
|
||||
|
||||
wal_level = 'hot_standby' if self.pg_major < 90600 else 'replica'
|
||||
self.config['bootstrap']['dcs']['postgresql']['parameters']['wal_level'] = wal_level
|
||||
|
||||
self.config['bootstrap']['dcs']['postgresql']['use_pg_rewind'] = True
|
||||
if self.pg_major >= 110000:
|
||||
self.config['postgresql']['authentication'].setdefault(
|
||||
'rewind', {'username': 'rewind_user'}).setdefault('password', NO_VALUE_MSG)
|
||||
|
||||
|
||||
class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
"""Object representing the Patroni config generated using information gathered from the running instance.
|
||||
|
||||
:ivar dsn: DSN string for the local instance to get GUC values from (if provided).
|
||||
:ivar parsed_dsn: DSN string parsed into a dictionary (see :func:`~patroni.postgresql.config.parse_dsn`).
|
||||
"""
|
||||
|
||||
def __init__(self, output_file: Optional[str] = None, dsn: Optional[str] = None) -> None:
|
||||
"""Additionally store the passed dsn (if any) in both original and parsed version and run config generation.
|
||||
|
||||
:param output_file: full path to the output file to be used.
|
||||
:param dsn: DSN string for the local instance to get GUC values from.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.PatroniException`: if DSN parsing failed.
|
||||
"""
|
||||
self.dsn = dsn
|
||||
self.parsed_dsn = {}
|
||||
|
||||
super().__init__(output_file)
|
||||
|
||||
@property
|
||||
def _get_hba_conn_types(self) -> Tuple[str, ...]:
|
||||
"""Return the connection types allowed.
|
||||
|
||||
If :attr:`~RunningClusterConfigGenerator.pg_major` is defined, adds additional parameters
|
||||
for PostgreSQL version >=16.
|
||||
|
||||
:returns: tuple of the connection methods allowed.
|
||||
"""
|
||||
allowed_types = ('local', 'host', 'hostssl', 'hostnossl', 'hostgssenc', 'hostnogssenc')
|
||||
if self.pg_major and self.pg_major >= 160000:
|
||||
allowed_types += ('include', 'include_if_exists', 'include_dir')
|
||||
return allowed_types
|
||||
|
||||
@property
|
||||
def _required_pg_params(self) -> List[str]:
|
||||
"""PG configuration prameters that have to be always present in the generated config.
|
||||
|
||||
:returns: list of the parameter names.
|
||||
"""
|
||||
return ['hba_file', 'ident_file', 'config_file', 'data_directory'] + \
|
||||
list(ConfigHandler.CMDLINE_OPTIONS.keys())
|
||||
|
||||
def _get_bin_dir_from_running_instance(self) -> str:
|
||||
"""Define the directory postgres binaries reside using postmaster's pid executable.
|
||||
|
||||
:returns: path to the PostgreSQL binaries directory.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.PatroniException`: if:
|
||||
|
||||
* pid could not be obtained from the ``postmaster.pid`` file; or
|
||||
* :exc:`OSError` occured during ``postmaster.pid`` file handling; or
|
||||
* the obtained postmaster pid doesn't exist.
|
||||
"""
|
||||
postmaster_pid = None
|
||||
data_dir = self.config['postgresql']['data_dir']
|
||||
try:
|
||||
with open(f"{data_dir}/postmaster.pid", 'r') as pid_file:
|
||||
postmaster_pid = pid_file.readline()
|
||||
if not postmaster_pid:
|
||||
raise PatroniException('Failed to obtain postmaster pid from postmaster.pid file')
|
||||
postmaster_pid = int(postmaster_pid.strip())
|
||||
except OSError as err:
|
||||
raise PatroniException(f'Error while reading postmaster.pid file: {err}')
|
||||
try:
|
||||
return os.path.dirname(psutil.Process(postmaster_pid).exe())
|
||||
except psutil.NoSuchProcess:
|
||||
raise PatroniException("Obtained postmaster pid doesn't exist.")
|
||||
|
||||
@contextmanager
|
||||
def _get_connection_cursor(self) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
"""Get cursor for the PG connection established based on the stored information.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.PatroniException`: if :exc:`psycopg.Error` occured.
|
||||
"""
|
||||
try:
|
||||
conn = psycopg.connect(dsn=self.dsn,
|
||||
password=self.config['postgresql']['authentication']['superuser']['password'])
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
conn.close()
|
||||
except psycopg.Error as e:
|
||||
raise PatroniException(f'Failed to establish PostgreSQL connection: {e}')
|
||||
|
||||
def _set_pg_params(self, cur: Union['cursor', 'Cursor[Any]']) -> None:
|
||||
"""Extend :attr:`~RunningClusterConfigGenerator.config` with the actual PG GUCs values.
|
||||
|
||||
THe following GUC values are set:
|
||||
|
||||
* Non-internal having configuration file, postmaster command line or environment variable
|
||||
as a source.
|
||||
|
||||
* List of the always required parameters (see :meth:`~RunningClusterConfigGenerator._required_pg_params`).
|
||||
|
||||
:param cur: connection cursor to use.
|
||||
"""
|
||||
cur.execute("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings "
|
||||
"WHERE context <> 'internal' "
|
||||
"AND source IN ('configuration file', 'command line', 'environment variable') "
|
||||
"AND category <> 'Write-Ahead Log / Recovery Target' "
|
||||
"AND setting <> '(disabled)' "
|
||||
"OR name = ANY(%s)", (self._required_pg_params,))
|
||||
|
||||
helper_dict = dict.fromkeys(['port', 'listen_addresses'])
|
||||
self.config['postgresql'].setdefault('parameters', {})
|
||||
for param, value in cur.fetchall():
|
||||
if param == 'data_directory':
|
||||
self.config['postgresql']['data_dir'] = value
|
||||
elif param == 'cluster_name' and value:
|
||||
self.config['scope'] = value
|
||||
elif param in ('archive_command', 'restore_command',
|
||||
'archive_cleanup_command', 'recovery_end_command',
|
||||
'ssl_passphrase_command', 'hba_file',
|
||||
'ident_file', 'config_file'):
|
||||
# write commands to the local config due to security implications
|
||||
# write hba/ident/config_file to local config to ensure they are not removed later
|
||||
self.config['postgresql']['parameters'][param] = value
|
||||
elif param in helper_dict:
|
||||
helper_dict[param] = value
|
||||
else:
|
||||
self.config['bootstrap']['dcs']['postgresql']['parameters'][param] = value
|
||||
|
||||
connect_port = self.parsed_dsn.get('port', os.getenv('PGPORT', helper_dict['port']))
|
||||
self.config['postgresql']['connect_address'] = f'{self._IP}:{connect_port}'
|
||||
self.config['postgresql']['listen'] = f'{helper_dict["listen_addresses"]}:{helper_dict["port"]}'
|
||||
|
||||
def _set_su_params(self) -> None:
|
||||
"""Extend :attr:`~RunningClusterConfigGenerator.config` with the superuser auth information.
|
||||
|
||||
Information set is based on the options used for connection.
|
||||
"""
|
||||
su_params: Dict[str, str] = {}
|
||||
for conn_param, env_var in _AUTH_ALLOWED_PARAMETERS_MAPPING.items():
|
||||
val = self.parsed_dsn.get(conn_param, os.getenv(env_var))
|
||||
if val:
|
||||
su_params[conn_param] = val
|
||||
patroni_env_su_username = ((self.config.get('authentication') or {}).get('superuser') or {}).get('username')
|
||||
patroni_env_su_pwd = ((self.config.get('authentication') or {}).get('superuser') or {}).get('password')
|
||||
# because we use "username" in the config for some reason
|
||||
su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser()
|
||||
su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \
|
||||
getpass('Please enter the user password:')
|
||||
self.config['postgresql']['authentication'] = {
|
||||
'superuser': su_params,
|
||||
'replication': {'username': NO_VALUE_MSG, 'password': NO_VALUE_MSG}
|
||||
}
|
||||
|
||||
def _set_conf_files(self) -> None:
|
||||
"""Extend :attr:`~RunningClusterConfigGenerator.config` with ``pg_hba.conf`` and ``pg_ident.conf`` content.
|
||||
|
||||
.. note::
|
||||
This function only defines ``postgresql.pg_hba`` and ``postgresql.pg_ident`` when
|
||||
``hba_file`` and ``ident_file`` are set to the defaults. It may happen these files
|
||||
are located outside of ``PGDATA`` and Patroni doesn't have write permissions for them.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.PatroniException`: if :exc:`OSError` occured during the conf files handling.
|
||||
"""
|
||||
default_hba_path = os.path.join(self.config['postgresql']['data_dir'], 'pg_hba.conf')
|
||||
if self.config['postgresql']['parameters']['hba_file'] == default_hba_path:
|
||||
try:
|
||||
self.config['postgresql']['pg_hba'] = list(
|
||||
filter(lambda i: i and i.split()[0] in self._get_hba_conn_types, read_stripped(default_hba_path)))
|
||||
except OSError as err:
|
||||
raise PatroniException(f'Failed to read pg_hba.conf: {err}')
|
||||
|
||||
default_ident_path = os.path.join(self.config['postgresql']['data_dir'], 'pg_ident.conf')
|
||||
if self.config['postgresql']['parameters']['ident_file'] == default_ident_path:
|
||||
try:
|
||||
self.config['postgresql']['pg_ident'] = [i for i in read_stripped(default_ident_path)
|
||||
if i and not i.startswith('#')]
|
||||
except OSError as err:
|
||||
raise PatroniException(f'Failed to read pg_ident.conf: {err}')
|
||||
if not self.config['postgresql']['pg_ident']:
|
||||
del self.config['postgresql']['pg_ident']
|
||||
|
||||
def _enrich_config_from_running_instance(self) -> None:
|
||||
"""Extend :attr:`~RunningClusterConfigGenerator.config` with the values gathered from the running instance.
|
||||
|
||||
Retrieve the following information from the running PostgreSQL instance:
|
||||
|
||||
* superuser auth parameters (see :meth:`~RunningClusterConfigGenerator._set_su_params`);
|
||||
* some GUC values (see :meth:`~RunningClusterConfigGenerator._set_pg_params`);
|
||||
* ``postgresql.connect_address``, ``postgresql.listen``;
|
||||
* ``postgresql.pg_hba`` and ``postgresql.pg_ident`` (see :meth:`~RunningClusterConfigGenerator._set_conf_files`)
|
||||
|
||||
And redefine ``scope`` with the ``cluster_name`` GUC value if set.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.PatroniException`: if the provided user doesn't have superuser privileges.
|
||||
"""
|
||||
self._set_su_params()
|
||||
|
||||
with self._get_connection_cursor() as cur:
|
||||
self.pg_major = getattr(cur.connection, 'server_version', 0)
|
||||
|
||||
if not parse_bool(cur.connection.info.parameter_status('is_superuser')):
|
||||
raise PatroniException('The provided user does not have superuser privilege')
|
||||
|
||||
self._set_pg_params(cur)
|
||||
|
||||
self._set_conf_files()
|
||||
|
||||
def generate(self) -> None:
|
||||
"""Generate config using the info gathered from the specified running PG instance.
|
||||
|
||||
Result is written to :attr:`~RunningClusterConfigGenerator.config`.
|
||||
"""
|
||||
if self.dsn:
|
||||
self.parsed_dsn = parse_dsn(self.dsn) or {}
|
||||
if not self.parsed_dsn:
|
||||
raise PatroniException('Failed to parse DSN string')
|
||||
|
||||
self._enrich_config_from_running_instance()
|
||||
self.config['postgresql']['bin_dir'] = self._get_bin_dir_from_running_instance()
|
||||
|
||||
|
||||
def generate_config(output_file: str, sample: bool, dsn: Optional[str]) -> None:
|
||||
"""Generate Patroni configuration file.
|
||||
|
||||
:param output_file: Full path to the configuration file to be used. If not provided, result is sent to ``stdout``.
|
||||
:param sample: Optional flag. If set, no source instance will be used - generate config with some sane defaults.
|
||||
:param dsn: Optional DSN string for the local instance to get GUC values from.
|
||||
"""
|
||||
try:
|
||||
if sample:
|
||||
config_generator = SampleConfigGenerator(output_file)
|
||||
else:
|
||||
config_generator = RunningClusterConfigGenerator(output_file, dsn)
|
||||
|
||||
config_generator.write_config()
|
||||
except PatroniException as e:
|
||||
sys.exit(str(e))
|
||||
except Exception as e:
|
||||
sys.exit(f'Unexpected exception: {e}')
|
||||
+1187
-241
File diff suppressed because it is too large
Load Diff
+106
-40
@@ -1,18 +1,57 @@
|
||||
"""Daemon processes abstraction module.
|
||||
|
||||
This module implements abstraction classes and functions for creating and managing daemon processes in Patroni.
|
||||
Currently it is only used for the main "Thread" of ``patroni`` and ``patroni_raft_controller`` commands.
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import abc
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import six
|
||||
import sys
|
||||
|
||||
from threading import Lock
|
||||
from typing import Any, Optional, Type, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .config import Config
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractPatroniDaemon(object):
|
||||
def get_base_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Create a basic argument parser with the arguments used for both patroni and raft controller daemon.
|
||||
|
||||
def __init__(self, config):
|
||||
:returns: 'argparse.ArgumentParser' object
|
||||
"""
|
||||
from .config import Config
|
||||
from .version import __version__
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
|
||||
parser.add_argument('configfile', nargs='?', default='',
|
||||
help='Patroni may also read the configuration from the {0} environment variable'
|
||||
.format(Config.PATRONI_CONFIG_VARIABLE))
|
||||
return parser
|
||||
|
||||
|
||||
class AbstractPatroniDaemon(abc.ABC):
|
||||
"""A Patroni daemon process.
|
||||
|
||||
.. note::
|
||||
|
||||
When inheriting from :class:`AbstractPatroniDaemon` you are expected to define the methods :func:`_run_cycle`
|
||||
to determine what it should do in each execution cycle, and :func:`_shutdown` to determine what it should do
|
||||
when shutting down.
|
||||
|
||||
:ivar logger: log handler used by this daemon.
|
||||
:ivar config: configuration options for this daemon.
|
||||
"""
|
||||
|
||||
def __init__(self, config: 'Config') -> None:
|
||||
"""Set up signal handlers, logging handler and configuration.
|
||||
|
||||
:param config: configuration options for this daemon.
|
||||
"""
|
||||
from patroni.log import PatroniLogger
|
||||
|
||||
self.setup_signal_handlers()
|
||||
@@ -21,20 +60,44 @@ class AbstractPatroniDaemon(object):
|
||||
self.config = config
|
||||
AbstractPatroniDaemon.reload_config(self, local=True)
|
||||
|
||||
def sighup_handler(self, *args):
|
||||
def sighup_handler(self, *_: Any) -> None:
|
||||
"""Handle SIGHUP signals.
|
||||
|
||||
Flag the daemon as "SIGHUP received".
|
||||
"""
|
||||
self._received_sighup = True
|
||||
|
||||
def api_sigterm(self):
|
||||
def api_sigterm(self) -> bool:
|
||||
"""Guarantee only a single SIGTERM is being processed.
|
||||
|
||||
Flag the daemon as "SIGTERM received" with a lock-based approach.
|
||||
|
||||
:returns: ``True`` if the daemon was flagged as "SIGTERM received".
|
||||
"""
|
||||
ret = False
|
||||
with self._sigterm_lock:
|
||||
if not self._received_sigterm:
|
||||
self._received_sigterm = True
|
||||
return True
|
||||
ret = True
|
||||
return ret
|
||||
|
||||
def sigterm_handler(self, *args):
|
||||
def sigterm_handler(self, *_: Any) -> None:
|
||||
"""Handle SIGTERM signals.
|
||||
|
||||
Terminate the daemon process through :func:`api_sigterm`.
|
||||
"""
|
||||
if self.api_sigterm():
|
||||
sys.exit()
|
||||
|
||||
def setup_signal_handlers(self):
|
||||
def setup_signal_handlers(self) -> None:
|
||||
"""Set up daemon signal handlers.
|
||||
|
||||
Set up SIGHUP and SIGTERM signal handlers.
|
||||
|
||||
.. note::
|
||||
|
||||
SIGHUP is only handled in non-Windows environments.
|
||||
"""
|
||||
self._received_sighup = False
|
||||
self._sigterm_lock = Lock()
|
||||
self._received_sigterm = False
|
||||
@@ -43,19 +106,34 @@ class AbstractPatroniDaemon(object):
|
||||
signal.signal(signal.SIGTERM, self.sigterm_handler)
|
||||
|
||||
@property
|
||||
def received_sigterm(self):
|
||||
def received_sigterm(self) -> bool:
|
||||
"""If daemon was signaled with SIGTERM."""
|
||||
with self._sigterm_lock:
|
||||
return self._received_sigterm
|
||||
|
||||
def reload_config(self, sighup=False, local=False):
|
||||
def reload_config(self, sighup: bool = False, local: Optional[bool] = False) -> None:
|
||||
"""Reload configuration.
|
||||
|
||||
:param sighup: if it is related to a SIGHUP signal.
|
||||
The sighup parameter could be used in the method overridden in a child class.
|
||||
:param local: will be ``True`` if there are changes in the local configuration file.
|
||||
"""
|
||||
if local:
|
||||
self.logger.reload_config(self.config.get('log', {}))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _run_cycle(self):
|
||||
"""_run_cycle"""
|
||||
def _run_cycle(self) -> None:
|
||||
"""Define what the daemon should do in each execution cycle.
|
||||
|
||||
def run(self):
|
||||
Keep being called in the daemon's main loop until the daemon is eventually terminated.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the daemon process.
|
||||
|
||||
Start the logger thread and keep running execution cycles until a SIGTERM is eventually received. Also reload
|
||||
configuration uppon receiving SIGHUP.
|
||||
"""
|
||||
self.logger.start()
|
||||
while not self.received_sigterm:
|
||||
if self._received_sighup:
|
||||
@@ -65,43 +143,31 @@ class AbstractPatroniDaemon(object):
|
||||
self._run_cycle()
|
||||
|
||||
@abc.abstractmethod
|
||||
def _shutdown(self):
|
||||
"""_shutdown"""
|
||||
def _shutdown(self) -> None:
|
||||
"""Define what the daemon should do when shutting down."""
|
||||
|
||||
def shutdown(self):
|
||||
def shutdown(self) -> None:
|
||||
"""Shut the daemon down when a SIGTERM is received.
|
||||
|
||||
Shut down the daemon process and the logger thread.
|
||||
"""
|
||||
with self._sigterm_lock:
|
||||
self._received_sigterm = True
|
||||
self._shutdown()
|
||||
self.logger.shutdown()
|
||||
|
||||
|
||||
def abstract_main(cls, validator=None):
|
||||
import argparse
|
||||
def abstract_main(cls: Type[AbstractPatroniDaemon], configfile: str) -> None:
|
||||
"""Create the main entry point of a given daemon process.
|
||||
|
||||
:param cls: a class that should inherit from :class:`AbstractPatroniDaemon`.
|
||||
:param configfile:
|
||||
"""
|
||||
from .config import Config, ConfigParseError
|
||||
from .version import __version__
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
|
||||
if validator:
|
||||
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
|
||||
parser.add_argument('configfile', nargs='?', default='',
|
||||
help='Patroni may also read the configuration from the {0} environment variable'
|
||||
.format(Config.PATRONI_CONFIG_VARIABLE))
|
||||
args = parser.parse_args()
|
||||
validate_config = validator and args.validate_config
|
||||
try:
|
||||
if validate_config:
|
||||
Config(args.configfile, validator=validator)
|
||||
sys.exit()
|
||||
|
||||
config = Config(args.configfile)
|
||||
config = Config(configfile)
|
||||
except ConfigParseError as e:
|
||||
if e.value:
|
||||
print(e.value, file=sys.stderr)
|
||||
if not validate_config:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
sys.exit(e.value)
|
||||
|
||||
controller = cls(config)
|
||||
try:
|
||||
|
||||
+1487
-523
File diff suppressed because it is too large
Load Diff
+170
-138
@@ -8,16 +8,19 @@ import ssl
|
||||
import time
|
||||
import urllib3
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections import defaultdict
|
||||
from consul import ConsulException, NotFound, base
|
||||
from http.client import HTTPException
|
||||
from urllib3.exceptions import HTTPError
|
||||
from six.moves.urllib.parse import urlencode, urlparse, quote
|
||||
from six.moves.http_client import HTTPException
|
||||
from urllib.parse import urlencode, urlparse, quote
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,12 +41,17 @@ class InvalidSession(ConsulException):
|
||||
"""invalid session"""
|
||||
|
||||
|
||||
Response = namedtuple('Response', 'code,headers,body,content')
|
||||
class Response(NamedTuple):
|
||||
code: int
|
||||
headers: Union[Mapping[str, str], Mapping[bytes, bytes], None]
|
||||
body: str
|
||||
content: bytes
|
||||
|
||||
|
||||
class HTTPClient(object):
|
||||
|
||||
def __init__(self, host='127.0.0.1', port=8500, token=None, scheme='http', verify=True, cert=None, ca_cert=None):
|
||||
def __init__(self, host: str = '127.0.0.1', port: int = 8500, token: Optional[str] = None, scheme: str = 'http',
|
||||
verify: bool = True, cert: Optional[str] = None, ca_cert: Optional[str] = None) -> None:
|
||||
self.token = token
|
||||
self._read_timeout = 10
|
||||
self.base_uri = uri(scheme, (host, port))
|
||||
@@ -59,23 +67,23 @@ class HTTPClient(object):
|
||||
if ca_cert:
|
||||
kwargs['ca_certs'] = ca_cert
|
||||
kwargs['cert_reqs'] = ssl.CERT_REQUIRED if verify or ca_cert else ssl.CERT_NONE
|
||||
self.http = urllib3.PoolManager(num_pools=10, maxsize=10, **kwargs)
|
||||
self._ttl = None
|
||||
self.http = urllib3.PoolManager(num_pools=10, maxsize=10, headers={}, **kwargs)
|
||||
self._ttl = 30
|
||||
|
||||
def set_read_timeout(self, timeout):
|
||||
self._read_timeout = timeout/3.0
|
||||
def set_read_timeout(self, timeout: float) -> None:
|
||||
self._read_timeout = timeout / 3.0
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
def ttl(self) -> int:
|
||||
return self._ttl
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
def set_ttl(self, ttl: int) -> bool:
|
||||
ret = self._ttl != ttl
|
||||
self._ttl = ttl
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def response(response):
|
||||
def response(response: urllib3.response.HTTPResponse) -> Response:
|
||||
content = response.data
|
||||
body = content.decode('utf-8')
|
||||
if response.status == 500:
|
||||
@@ -88,14 +96,19 @@ class HTTPClient(object):
|
||||
raise ConsulInternalError(msg)
|
||||
return Response(response.status, response.headers, body, content)
|
||||
|
||||
def uri(self, path, params=None):
|
||||
def uri(self, path: str,
|
||||
params: Union[None, Dict[str, Any], List[Tuple[str, Any]], Tuple[Tuple[str, Any], ...]] = None) -> str:
|
||||
return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '')
|
||||
|
||||
def __getattr__(self, method):
|
||||
def __getattr__(self, method: str) -> Callable[[Callable[[Response], Union[bool, Any, Tuple[str, Any]]],
|
||||
str, Union[None, Dict[str, Any], List[Tuple[str, Any]]],
|
||||
str, Optional[Dict[str, str]]], Union[bool, Any, Tuple[str, Any]]]:
|
||||
if method not in ('get', 'post', 'put', 'delete'):
|
||||
raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method))
|
||||
|
||||
def wrapper(callback, path, params=None, data='', headers=None):
|
||||
def wrapper(callback: Callable[[Response], Union[bool, Any, Tuple[str, Any]]], path: str,
|
||||
params: Union[None, Dict[str, Any], List[Tuple[str, Any]]] = None, data: str = '',
|
||||
headers: Optional[Dict[str, str]] = None) -> Union[bool, Any, Tuple[str, Any]]:
|
||||
# python-consul doesn't allow to specify ttl smaller then 10 seconds
|
||||
# because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack...
|
||||
if method == 'put' and path == '/v1/session/create':
|
||||
@@ -106,14 +119,14 @@ class HTTPClient(object):
|
||||
data = data[:-1] + ', ' + ttl + '}'
|
||||
if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params
|
||||
params = {k: v for k, v in params}
|
||||
kwargs = {'retries': 0, 'preload_content': False, 'body': data}
|
||||
kwargs: Dict[str, Any] = {'retries': 0, 'preload_content': False, 'body': data}
|
||||
if method == 'get' and isinstance(params, dict) and 'index' in params:
|
||||
timeout = float(params['wait'][:-1]) if 'wait' in params else 300
|
||||
# According to the documentation a small random amount of additional wait time is added to the
|
||||
# supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds
|
||||
# up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a
|
||||
# response rather read timeout we will add to the timeout a slightly bigger value.
|
||||
kwargs['timeout'] = timeout + max(timeout/15.0, 1)
|
||||
kwargs['timeout'] = timeout + max(timeout / 15.0, 1)
|
||||
else:
|
||||
kwargs['timeout'] = self._read_timeout
|
||||
kwargs['headers'] = (headers or {}).copy()
|
||||
@@ -127,13 +140,43 @@ class HTTPClient(object):
|
||||
|
||||
class ConsulClient(base.Consul):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Consul client with Patroni customisations.
|
||||
|
||||
.. note::
|
||||
|
||||
Parameters, *token*, *cert* and *ca_cert* are not passed to the parent class :class:`consul.base.Consul`.
|
||||
|
||||
Original class documentation,
|
||||
|
||||
*token* is an optional ``ACL token``. If supplied it will be used by
|
||||
default for all requests made with this client session. It's still
|
||||
possible to override this token by passing a token explicitly for a
|
||||
request.
|
||||
|
||||
*consistency* sets the consistency mode to use by default for all reads
|
||||
that support the consistency option. It's still possible to override
|
||||
this by passing explicitly for a given request. *consistency* can be
|
||||
either 'default', 'consistent' or 'stale'.
|
||||
|
||||
*dc* is the datacenter that this agent will communicate with.
|
||||
By default, the datacenter of the host is used.
|
||||
|
||||
*verify* is whether to verify the SSL certificate for HTTPS requests
|
||||
|
||||
*cert* client side certificates for HTTPS requests
|
||||
|
||||
:param args: positional arguments to pass to :class:`consul.base.Consul`
|
||||
:param kwargs: keyword arguments, with *cert*, *ca_cert* and *token* removed, passed to
|
||||
:class:`consul.base.Consul`
|
||||
"""
|
||||
self._cert = kwargs.pop('cert', None)
|
||||
self._ca_cert = kwargs.pop('ca_cert', None)
|
||||
self.token = kwargs.get('token')
|
||||
super(ConsulClient, self).__init__(*args, **kwargs)
|
||||
|
||||
def http_connect(self, *args, **kwargs):
|
||||
def http_connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
|
||||
kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args)))
|
||||
if self._cert:
|
||||
kwargs['cert'] = self._cert
|
||||
@@ -143,17 +186,17 @@ class ConsulClient(base.Consul):
|
||||
kwargs['token'] = self.token
|
||||
return HTTPClient(**kwargs)
|
||||
|
||||
def connect(self, *args, **kwargs):
|
||||
def connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
|
||||
return self.http_connect(*args, **kwargs)
|
||||
|
||||
def reload_config(self, config):
|
||||
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||
self.http.token = self.token = config.get('token')
|
||||
self.consistency = config.get('consistency', 'default')
|
||||
self.dc = config.get('dc')
|
||||
|
||||
|
||||
def catch_consul_errors(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
def catch_consul_errors(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
|
||||
@@ -161,24 +204,25 @@ def catch_consul_errors(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def force_if_last_failed(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
if wrapper.last_result is False:
|
||||
def force_if_last_failed(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if getattr(wrapper, 'last_result', None) is False:
|
||||
kwargs['force'] = True
|
||||
wrapper.last_result = func(*args, **kwargs)
|
||||
return wrapper.last_result
|
||||
last_result = func(*args, **kwargs)
|
||||
setattr(wrapper, 'last_result', last_result)
|
||||
return last_result
|
||||
|
||||
wrapper.last_result = None
|
||||
setattr(wrapper, 'last_result', None)
|
||||
return wrapper
|
||||
|
||||
|
||||
def service_name_from_scope_name(scope_name):
|
||||
def service_name_from_scope_name(scope_name: str) -> str:
|
||||
"""Translate scope name to service name which can be used in dns.
|
||||
|
||||
230 = 253 - len('replica.') - len('.service.consul')
|
||||
"""
|
||||
|
||||
def replace_char(match):
|
||||
def replace_char(match: Any) -> str:
|
||||
c = match.group(0)
|
||||
return '-' if c in '. _' else "u{:04d}".format(ord(c))
|
||||
|
||||
@@ -188,7 +232,7 @@ def service_name_from_scope_name(scope_name):
|
||||
|
||||
class Consul(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Consul, self).__init__(config)
|
||||
self._base_path = self._base_path[1:]
|
||||
self._scope = config['scope']
|
||||
@@ -198,9 +242,9 @@ class Consul(AbstractDCS):
|
||||
retry_exceptions=(ConsulInternalError, HTTPException,
|
||||
HTTPError, socket.error, socket.timeout))
|
||||
|
||||
kwargs = {}
|
||||
if 'url' in config:
|
||||
r = urlparse(config['url'])
|
||||
url: str = config['url']
|
||||
r = urlparse(url)
|
||||
config.update({'scheme': r.scheme, 'host': r.hostname, 'port': r.port or 8500})
|
||||
elif 'host' in config:
|
||||
host, port = split_host_port(config.get('host', '127.0.0.1:8500'), 8500)
|
||||
@@ -215,7 +259,7 @@ class Consul(AbstractDCS):
|
||||
config['cert'] = (config['cert'], config['key'])
|
||||
|
||||
config_keys = ('host', 'port', 'token', 'scheme', 'cert', 'ca_cert', 'dc', 'consistency')
|
||||
kwargs = {p: config.get(p) for p in config_keys if config.get(p)}
|
||||
kwargs: Dict[str, Any] = {p: config.get(p) for p in config_keys if config.get(p)}
|
||||
|
||||
verify = config.get('verify')
|
||||
if not isinstance(verify, bool):
|
||||
@@ -240,10 +284,10 @@ class Consul(AbstractDCS):
|
||||
self.create_session()
|
||||
self._previous_loop_token = self._client.token
|
||||
|
||||
def retry(self, *args, **kwargs):
|
||||
return self._retry.copy()(*args, **kwargs)
|
||||
def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
return self._retry.copy()(method, *args, **kwargs)
|
||||
|
||||
def create_session(self):
|
||||
def create_session(self) -> None:
|
||||
while not self._session:
|
||||
try:
|
||||
self.refresh_session()
|
||||
@@ -251,13 +295,14 @@ class Consul(AbstractDCS):
|
||||
logger.info('waiting on consul')
|
||||
time.sleep(5)
|
||||
|
||||
def reload_config(self, config):
|
||||
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
|
||||
super(Consul, self).reload_config(config)
|
||||
|
||||
consul_config = config.get('consul', {})
|
||||
self._client.reload_config(consul_config)
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
self._service_tags = sorted(consul_config.get('service_tags', []))
|
||||
self._service_tags: List[str] = consul_config.get('service_tags', [])
|
||||
self._service_tags.sort()
|
||||
|
||||
should_register_service = consul_config.get('register_service', False)
|
||||
if should_register_service and not self._register_service:
|
||||
@@ -266,29 +311,30 @@ class Consul(AbstractDCS):
|
||||
self._previous_loop_register_service = self._register_service
|
||||
self._register_service = should_register_service
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x
|
||||
def set_ttl(self, ttl: int) -> Optional[bool]:
|
||||
if self._client.http.set_ttl(ttl / 2.0): # Consul multiplies the TTL by 2x
|
||||
self._session = None
|
||||
self.__do_not_watch = True
|
||||
return None
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
def ttl(self) -> int:
|
||||
return self._client.http.ttl * 2 # we multiply the value by 2 because it was divided in the `set_ttl()` method
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
def set_retry_timeout(self, retry_timeout: int) -> None:
|
||||
self._retry.deadline = retry_timeout
|
||||
self._client.http.set_read_timeout(retry_timeout)
|
||||
|
||||
def adjust_ttl(self):
|
||||
def adjust_ttl(self) -> None:
|
||||
try:
|
||||
settings = self._client.agent.self()
|
||||
min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000)/1000000000.0
|
||||
min_ttl = (settings['Config']['SessionTTLMin'] or 10000000000) / 1000000000.0
|
||||
logger.warning('Changing Session TTL from %s to %s', self._client.http.ttl, min_ttl)
|
||||
self._client.http.set_ttl(min_ttl)
|
||||
except Exception:
|
||||
logger.exception('adjust_ttl')
|
||||
|
||||
def _do_refresh_session(self, force=False):
|
||||
def _do_refresh_session(self, force: bool = False) -> bool:
|
||||
""":returns: `!True` if it had to create new session"""
|
||||
if not force and self._session and self._last_session_refresh + self._loop_wait > time.time():
|
||||
return False
|
||||
@@ -312,7 +358,7 @@ class Consul(AbstractDCS):
|
||||
self._last_session_refresh = time.time()
|
||||
return ret
|
||||
|
||||
def refresh_session(self):
|
||||
def refresh_session(self) -> bool:
|
||||
try:
|
||||
return self.retry(self._do_refresh_session)
|
||||
except (ConsulException, RetryFailedError):
|
||||
@@ -320,10 +366,10 @@ class Consul(AbstractDCS):
|
||||
raise ConsulError('Failed to renew/create session')
|
||||
|
||||
@staticmethod
|
||||
def member(node):
|
||||
def member(node: Dict[str, str]) -> Member:
|
||||
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
|
||||
|
||||
def _cluster_from_nodes(self, nodes):
|
||||
def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
|
||||
# get initialize flag
|
||||
initialize = nodes.get(self._INITIALIZE)
|
||||
initialize = initialize and initialize['Value']
|
||||
@@ -337,23 +383,8 @@ class Consul(AbstractDCS):
|
||||
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
|
||||
|
||||
# get last known leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['Value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['Value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status['Value'])
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -382,22 +413,26 @@ class Consul(AbstractDCS):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path):
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True)
|
||||
@property
|
||||
def _consistency(self) -> str:
|
||||
return 'consistent' if self._ctl else self._client.consistency
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
if results is None:
|
||||
raise NotFound
|
||||
nodes = {}
|
||||
return Cluster.empty()
|
||||
nodes: Dict[str, Dict[str, Any]] = {}
|
||||
for node in results:
|
||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||
nodes[node['Key'][len(path):]] = node
|
||||
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path):
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True)
|
||||
clusters = defaultdict(dict)
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
|
||||
for node in results or []:
|
||||
key = node['Key'][len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
@@ -405,17 +440,17 @@ class Consul(AbstractDCS):
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
|
||||
def _load_cluster(self, path, loader):
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
try:
|
||||
return loader(path)
|
||||
except NotFound:
|
||||
return Cluster(None, None, None, None, [], None, None, None, None, None)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise ConsulError('Consul is not responding properly')
|
||||
|
||||
@catch_consul_errors
|
||||
def touch_member(self, data):
|
||||
def touch_member(self, data: Dict[str, Any]) -> bool:
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
|
||||
@@ -447,30 +482,32 @@ class Consul(AbstractDCS):
|
||||
logger.exception('touch_member')
|
||||
return False
|
||||
|
||||
def _set_service_name(self):
|
||||
def _set_service_name(self) -> None:
|
||||
self._service_name = service_name_from_scope_name(self._scope)
|
||||
if self._scope != self._service_name:
|
||||
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope)
|
||||
|
||||
@catch_consul_errors
|
||||
def register_service(self, service_name, **kwargs):
|
||||
def register_service(self, service_name: str, **kwargs: Any) -> bool:
|
||||
logger.info('Register service %s, params %s', service_name, kwargs)
|
||||
return self._client.agent.service.register(service_name, **kwargs)
|
||||
|
||||
@catch_consul_errors
|
||||
def deregister_service(self, service_id):
|
||||
def deregister_service(self, service_id: str) -> bool:
|
||||
logger.info('Deregister service %s', service_id)
|
||||
# service_id can contain special characters, but is used as part of uri in deregister request
|
||||
service_id = quote(service_id)
|
||||
return self._client.agent.service.deregister(service_id)
|
||||
|
||||
def _update_service(self, data):
|
||||
def _update_service(self, data: Dict[str, Any]) -> Optional[bool]:
|
||||
service_name = self._service_name
|
||||
role = data['role'].replace('_', '-')
|
||||
state = data['state']
|
||||
api_parts = urlparse(data['api_url'])
|
||||
api_url: str = data['api_url']
|
||||
api_parts = urlparse(api_url)
|
||||
api_parts = api_parts._replace(path='/{0}'.format(role))
|
||||
conn_parts = urlparse(data['conn_url'])
|
||||
conn_url: str = data['conn_url']
|
||||
conn_parts = urlparse(conn_url)
|
||||
check = base.Check.http(api_parts.geturl(), self._service_check_interval,
|
||||
deregister='{0}s'.format(self._client.http.ttl * 10))
|
||||
if self._service_check_tls_server_name is not None:
|
||||
@@ -506,7 +543,7 @@ class Consul(AbstractDCS):
|
||||
logger.warning('Could not register service: unknown role type %s', role)
|
||||
|
||||
@force_if_last_failed
|
||||
def update_service(self, old_data, new_data, force=False):
|
||||
def update_service(self, old_data: Dict[str, Any], new_data: Dict[str, Any], force: bool = False) -> Optional[bool]:
|
||||
update = False
|
||||
|
||||
for key in ['role', 'api_url', 'conn_url', 'state']:
|
||||
@@ -523,30 +560,26 @@ class Consul(AbstractDCS):
|
||||
):
|
||||
return self._update_service(new_data)
|
||||
|
||||
def _do_attempt_to_acquire_leader(self, retry):
|
||||
def _do_attempt_to_acquire_leader(self, retry: Retry) -> bool:
|
||||
try:
|
||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
except InvalidSession:
|
||||
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
|
||||
self._session = None
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
retry.ensure_deadline(0)
|
||||
|
||||
retry(self._do_refresh_session)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise ConsulError('_do_attempt_to_acquire_leader timeout')
|
||||
retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
|
||||
|
||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
|
||||
@catch_return_false_exception
|
||||
def attempt_to_acquire_leader(self):
|
||||
def attempt_to_acquire_leader(self) -> bool:
|
||||
retry = self._retry.copy()
|
||||
self._run_and_handle_exceptions(self._do_refresh_session, retry=retry)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise ConsulError('attempt_to_acquire_leader timeout')
|
||||
retry.ensure_deadline(1, ConsulError('attempt_to_acquire_leader timeout'))
|
||||
|
||||
ret = self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry, retry=None)
|
||||
if not ret:
|
||||
@@ -554,31 +587,31 @@ class Consul(AbstractDCS):
|
||||
|
||||
return ret
|
||||
|
||||
def take_leader(self):
|
||||
def take_leader(self) -> bool:
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
@catch_consul_errors
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._client.kv.put(self.failover_path, value, cas=index)
|
||||
def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return self._client.kv.put(self.failover_path, value, cas=version)
|
||||
|
||||
@catch_consul_errors
|
||||
def set_config_value(self, value, index=None):
|
||||
return self._client.kv.put(self.config_path, value, cas=index)
|
||||
def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return self._client.kv.put(self.config_path, value, cas=version)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
def _write_leader_optime(self, last_lsn: str) -> bool:
|
||||
return self._client.kv.put(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_status(self, value):
|
||||
def _write_status(self, value: str) -> bool:
|
||||
return self._client.kv.put(self.status_path, value)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_failsafe(self, value):
|
||||
def _write_failsafe(self, value: str) -> bool:
|
||||
return self._client.kv.put(self.failsafe_path, value)
|
||||
|
||||
@staticmethod
|
||||
def _run_and_handle_exceptions(method, *args, **kwargs):
|
||||
def _run_and_handle_exceptions(method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
retry = kwargs.pop('retry', None)
|
||||
try:
|
||||
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
|
||||
@@ -588,73 +621,72 @@ class Consul(AbstractDCS):
|
||||
raise ReturnFalseException
|
||||
|
||||
@catch_return_false_exception
|
||||
def _update_leader(self):
|
||||
def _update_leader(self, leader: Leader) -> bool:
|
||||
retry = self._retry.copy()
|
||||
|
||||
self._run_and_handle_exceptions(self._do_refresh_session, True, retry=retry)
|
||||
|
||||
if self._session:
|
||||
cluster = self.cluster
|
||||
leader_session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
|
||||
if leader_session != self._session:
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
raise ConsulError('update_leader timeout')
|
||||
logger.warning('Recreating the leader key due to session mismatch')
|
||||
if cluster.leader:
|
||||
self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=cluster.leader.index)
|
||||
if self._session and leader.session != self._session:
|
||||
retry.ensure_deadline(1, ConsulError('update_leader timeout'))
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 0.5:
|
||||
raise ConsulError('update_leader timeout')
|
||||
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path,
|
||||
self._name, acquire=self._session)
|
||||
logger.warning('Recreating the leader key due to session mismatch')
|
||||
self._run_and_handle_exceptions(self._client.kv.delete, self.leader_path, cas=leader.version)
|
||||
|
||||
retry.ensure_deadline(0.5, ConsulError('update_leader timeout'))
|
||||
|
||||
self._run_and_handle_exceptions(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
|
||||
return bool(self._session)
|
||||
|
||||
@catch_consul_errors
|
||||
def initialize(self, create_new=True, sysid=''):
|
||||
def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
|
||||
kwargs = {'cas': 0} if create_new else {}
|
||||
return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs)
|
||||
|
||||
@catch_consul_errors
|
||||
def cancel_initialization(self):
|
||||
def cancel_initialization(self) -> bool:
|
||||
return self.retry(self._client.kv.delete, self.initialize_path)
|
||||
|
||||
@catch_consul_errors
|
||||
def delete_cluster(self):
|
||||
def delete_cluster(self) -> bool:
|
||||
return self.retry(self._client.kv.delete, self.client_path(''), recurse=True)
|
||||
|
||||
@catch_consul_errors
|
||||
def set_history_value(self, value):
|
||||
def set_history_value(self, value: str) -> bool:
|
||||
return self._client.kv.put(self.history_path, value)
|
||||
|
||||
@catch_consul_errors
|
||||
def _delete_leader(self):
|
||||
cluster = self.cluster
|
||||
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
|
||||
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index)
|
||||
def _delete_leader(self, leader: Leader) -> bool:
|
||||
return self._client.kv.delete(self.leader_path, cas=int(leader.version))
|
||||
|
||||
@catch_consul_errors
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
return self.retry(self._client.kv.put, self.sync_path, value, cas=index)
|
||||
def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
|
||||
retry = self._retry.copy()
|
||||
ret = retry(self._client.kv.put, self.sync_path, value, cas=version)
|
||||
if ret: # We have no other choise, only read after write :(
|
||||
if not retry.ensure_deadline(0.5):
|
||||
return False
|
||||
_, ret = self.retry(self._client.kv.get, self.sync_path, consistency='consistent')
|
||||
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
|
||||
return ret['ModifyIndex']
|
||||
return False
|
||||
|
||||
@catch_consul_errors
|
||||
def delete_sync_state(self, index=None):
|
||||
return self.retry(self._client.kv.delete, self.sync_path, cas=index)
|
||||
def delete_sync_state(self, version: Optional[int] = None) -> bool:
|
||||
return self.retry(self._client.kv.delete, self.sync_path, cas=version)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
|
||||
self._last_session_refresh = 0
|
||||
if self.__do_not_watch:
|
||||
self.__do_not_watch = False
|
||||
return True
|
||||
|
||||
if leader_index:
|
||||
if leader_version:
|
||||
end_time = time.time() + timeout
|
||||
while timeout >= 1:
|
||||
try:
|
||||
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's')
|
||||
return str(idx) != str(leader_index)
|
||||
idx, _ = self._client.kv.get(self.leader_path, index=leader_version, wait=str(timeout) + 's')
|
||||
return str(idx) != str(leader_version)
|
||||
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
|
||||
logger.exception('watch')
|
||||
|
||||
|
||||
+241
-191
@@ -6,7 +6,6 @@ import logging
|
||||
import os
|
||||
import urllib3.util.connection
|
||||
import random
|
||||
import six
|
||||
import socket
|
||||
import time
|
||||
|
||||
@@ -14,18 +13,21 @@ from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from dns.exception import DNSException
|
||||
from dns import resolver
|
||||
from http.client import HTTPException
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
from urllib3 import Timeout
|
||||
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
||||
from six.moves.queue import Queue
|
||||
from six.moves.http_client import HTTPException
|
||||
from six.moves.urllib_parse import urlparse
|
||||
from threading import Thread
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState,\
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..request import get as requests_get
|
||||
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,18 +40,21 @@ class EtcdError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
_AddrInfo = Tuple[socket.AddressFamily, socket.SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]]
|
||||
|
||||
|
||||
class DnsCachingResolver(Thread):
|
||||
|
||||
def __init__(self, cache_time=600.0, cache_fail_time=30.0):
|
||||
def __init__(self, cache_time: float = 600.0, cache_fail_time: float = 30.0) -> None:
|
||||
super(DnsCachingResolver, self).__init__()
|
||||
self._cache = {}
|
||||
self._cache: Dict[Tuple[str, int], Tuple[float, List[_AddrInfo]]] = {}
|
||||
self._cache_time = cache_time
|
||||
self._cache_fail_time = cache_fail_time
|
||||
self._resolve_queue = Queue()
|
||||
self._resolve_queue: Queue[Tuple[Tuple[str, int], int]] = Queue()
|
||||
self.daemon = True
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
(host, port), attempt = self._resolve_queue.get()
|
||||
response = self._do_resolve(host, port)
|
||||
@@ -60,7 +65,7 @@ class DnsCachingResolver(Thread):
|
||||
self.resolve_async(host, port, attempt + 1)
|
||||
time.sleep(1)
|
||||
|
||||
def resolve(self, host, port):
|
||||
def resolve(self, host: str, port: int) -> List[_AddrInfo]:
|
||||
current_time = time.time()
|
||||
cached_time, response = self._cache.get((host, port), (0, []))
|
||||
time_passed = current_time - cached_time
|
||||
@@ -71,14 +76,14 @@ class DnsCachingResolver(Thread):
|
||||
response = new_response
|
||||
return response
|
||||
|
||||
def resolve_async(self, host, port, attempt=0):
|
||||
def resolve_async(self, host: str, port: int, attempt: int = 0) -> None:
|
||||
self._resolve_queue.put(((host, port), attempt))
|
||||
|
||||
def remove(self, host, port):
|
||||
def remove(self, host: str, port: int) -> None:
|
||||
self._cache.pop((host, port), None)
|
||||
|
||||
@staticmethod
|
||||
def _do_resolve(host, port):
|
||||
def _do_resolve(host: str, port: int) -> List[_AddrInfo]:
|
||||
try:
|
||||
return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
|
||||
except Exception as e:
|
||||
@@ -86,21 +91,21 @@ class DnsCachingResolver(Thread):
|
||||
return []
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
|
||||
def __init__(self, config, dns_resolver, cache_ttl=300):
|
||||
ERROR_CLS: Type[Exception]
|
||||
|
||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||
self._dns_resolver = dns_resolver
|
||||
self.set_machines_cache_ttl(cache_ttl)
|
||||
self._machines_cache_updated = 0
|
||||
args = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'username', 'password',
|
||||
'cert', 'ca_cert') if config.get(p)}
|
||||
super(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **args)
|
||||
kwargs = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'version_prefix',
|
||||
'username', 'password', 'cert', 'ca_cert') if config.get(p)}
|
||||
super(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **kwargs)
|
||||
# For some reason python3-etcd on debian and ubuntu are not based on the latest version
|
||||
# Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied
|
||||
self.http.connection_pool_kw.pop('ssl_version', None)
|
||||
self._config = config
|
||||
self._initial_machines_cache = []
|
||||
self._load_machines_cache()
|
||||
self._allow_reconnect = True
|
||||
# allow passing retry argument to api_execute in params
|
||||
@@ -108,7 +113,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
self._read_options.add('retry')
|
||||
self._del_conditions.add('retry')
|
||||
|
||||
def _calculate_timeouts(self, etcd_nodes, timeout=None):
|
||||
def _calculate_timeouts(self, etcd_nodes: int, timeout: Optional[float] = None) -> Tuple[int, float, int]:
|
||||
"""Calculate a request timeout and number of retries per single etcd node.
|
||||
In case if the timeout per node is too small (less than one second) we will reduce the number of nodes.
|
||||
For the cluster with only one node we will try to do 2 retries.
|
||||
@@ -135,54 +140,48 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
|
||||
return etcd_nodes, per_node_timeout, per_node_retries - 1
|
||||
|
||||
def reload_config(self, config):
|
||||
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||
self.username = config.get('username')
|
||||
self.password = config.get('password')
|
||||
|
||||
def _get_headers(self):
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None
|
||||
return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
|
||||
|
||||
def _prepare_common_parameters(self, etcd_nodes, timeout=None):
|
||||
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect, 'preload_content': False}
|
||||
def _prepare_common_parameters(self, etcd_nodes: int, timeout: Optional[float] = None) -> Dict[str, Any]:
|
||||
kwargs: Dict[str, Any] = {'headers': self._get_headers(),
|
||||
'redirect': self.allow_redirect, 'preload_content': False}
|
||||
|
||||
if timeout is not None:
|
||||
kwargs.update(retries=0, timeout=timeout)
|
||||
else:
|
||||
_, per_node_timeout, per_node_retries = self._calculate_timeouts(etcd_nodes)
|
||||
connect_timeout = max(1, per_node_timeout/2)
|
||||
connect_timeout = max(1.0, per_node_timeout / 2.0)
|
||||
kwargs.update(timeout=Timeout(connect=connect_timeout, total=per_node_timeout), retries=per_node_retries)
|
||||
return kwargs
|
||||
|
||||
def set_machines_cache_ttl(self, cache_ttl):
|
||||
def set_machines_cache_ttl(self, cache_ttl: int) -> None:
|
||||
self._machines_cache_ttl = cache_ttl
|
||||
|
||||
@abc.abstractmethod
|
||||
def _prepare_get_members(self, etcd_nodes):
|
||||
def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
|
||||
"""returns: request parameters"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_members(self, base_uri, **kwargs):
|
||||
def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
|
||||
"""returns: list of clientURLs"""
|
||||
|
||||
@property
|
||||
def machines_cache(self):
|
||||
def machines_cache(self) -> List[str]:
|
||||
base_uri, cache = self._base_uri, self._machines_cache
|
||||
return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri]
|
||||
|
||||
@property
|
||||
def machines(self):
|
||||
"""Original `machines` method(property) of `etcd.Client` class raise exception
|
||||
when it failed to get list of etcd cluster members. This method is being called
|
||||
only when request failed on one of the etcd members during `api_execute` call.
|
||||
For us it's more important to execute original request rather then get new topology
|
||||
of etcd cluster. So we will catch this exception and return empty list of machines.
|
||||
Later, during next `api_execute` call we will forcefully update machines_cache.
|
||||
def _get_machines_list(self, machines_cache: List[str]) -> List[str]:
|
||||
"""Gets list of members from Etcd cluster using API
|
||||
|
||||
Also this method implements the same timeout-retry logic as `api_execute`, because
|
||||
the original method was retrying 2 times with the `read_timeout` on each node."""
|
||||
|
||||
machines_cache = self.machines_cache
|
||||
:param machines_cache: initial list of Etcd members
|
||||
:returns: list of clientURLs retrieved from Etcd cluster
|
||||
:raises EtcdConnectionFailed: if failed"""
|
||||
kwargs = self._prepare_get_members(len(machines_cache))
|
||||
|
||||
for base_uri in machines_cache:
|
||||
@@ -200,10 +199,30 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
|
||||
raise etcd.EtcdConnectionFailed('No more machines in the cluster')
|
||||
|
||||
def set_read_timeout(self, timeout):
|
||||
@property
|
||||
def machines(self) -> List[str]:
|
||||
"""Original `machines` method(property) of `etcd.Client` class raise exception
|
||||
when it failed to get list of etcd cluster members. This method is being called
|
||||
only when request failed on one of the etcd members during `api_execute` call.
|
||||
For us it's more important to execute original request rather then get new topology
|
||||
of etcd cluster. So we will catch this exception and return empty list of machines.
|
||||
Later, during next `api_execute` call we will forcefully update machines_cache.
|
||||
|
||||
Also this method implements the same timeout-retry logic as `api_execute`, because
|
||||
the original method was retrying 2 times with the `read_timeout` on each node.
|
||||
|
||||
After the next refactoring the whole logic was moved to the _get_machines_list() method."""
|
||||
|
||||
return self._get_machines_list(self.machines_cache)
|
||||
|
||||
def set_read_timeout(self, timeout: float) -> None:
|
||||
self._read_timeout = timeout
|
||||
|
||||
def _do_http_request(self, retry, machines_cache, request_executor, method, path, fields=None, **kwargs):
|
||||
def _do_http_request(self, retry: Optional[Retry], machines_cache: List[str],
|
||||
request_executor: Callable[..., urllib3.response.HTTPResponse],
|
||||
method: str, path: str, fields: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any) -> urllib3.response.HTTPResponse:
|
||||
is_watch_request = isinstance(fields, dict) and fields.get('wait') == 'true'
|
||||
if fields is not None:
|
||||
kwargs['fields'] = fields
|
||||
some_request_failed = False
|
||||
@@ -226,8 +245,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
# whether the key didn't received an update or there is a network problem.
|
||||
elif i + 1 < len(machines_cache):
|
||||
self.set_base_uri(machines_cache[i + 1])
|
||||
if (isinstance(fields, dict) and fields.get("wait") == "true" and
|
||||
isinstance(e, (ReadTimeoutError, ProtocolError))):
|
||||
if is_watch_request and isinstance(e, (ReadTimeoutError, ProtocolError)):
|
||||
logger.debug("Watch timed out.")
|
||||
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
|
||||
logger.error("Request to server %s failed: %r", base_uri, e)
|
||||
@@ -239,10 +257,12 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
raise etcd.EtcdConnectionFailed('No more machines in the cluster')
|
||||
|
||||
@abc.abstractmethod
|
||||
def _prepare_request(self, kwargs, params=None, method=None):
|
||||
def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
|
||||
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
|
||||
"""returns: request_executor"""
|
||||
|
||||
def api_execute(self, path, method, params=None, timeout=None):
|
||||
def api_execute(self, path: str, method: str, params: Optional[Dict[str, Any]] = None,
|
||||
timeout: Optional[float] = None) -> Any:
|
||||
retry = params.pop('retry', None) if isinstance(params, dict) else None
|
||||
|
||||
# Update machines_cache if previous attempt of update has failed
|
||||
@@ -270,6 +290,8 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
etcd_nodes = len(machines_cache)
|
||||
except Exception as e:
|
||||
logger.debug('Failed to update list of etcd nodes: %r', e)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(retry, Retry) # etcd.EtcdConnectionFailed is raised only if retry is not None!
|
||||
sleeptime = retry.sleeptime
|
||||
remaining_time = retry.stoptime - sleeptime - time.time()
|
||||
nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time)
|
||||
@@ -280,22 +302,22 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
retry.sleep_func(sleeptime)
|
||||
retry.update_delay()
|
||||
# We still have some time left. Partially reduce `machines_cache` and retry request
|
||||
kwargs.update(timeout=Timeout(connect=max(1, timeout/2), total=timeout), retries=retries)
|
||||
kwargs.update(timeout=Timeout(connect=max(1.0, timeout / 2.0), total=timeout), retries=retries)
|
||||
machines_cache = machines_cache[:nodes]
|
||||
|
||||
@staticmethod
|
||||
def get_srv_record(host):
|
||||
def get_srv_record(host: str) -> List[Tuple[str, int]]:
|
||||
try:
|
||||
return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')]
|
||||
except DNSException:
|
||||
return []
|
||||
|
||||
def _get_machines_cache_from_srv(self, srv, srv_suffix=None):
|
||||
def _get_machines_cache_from_srv(self, srv: str, srv_suffix: Optional[str] = None) -> List[str]:
|
||||
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
|
||||
This record should contain list of host and peer ports which could be used to run
|
||||
'GET http://{host}:{port}/members' request (peer protocol)"""
|
||||
|
||||
ret = []
|
||||
ret: List[str] = []
|
||||
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
|
||||
r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r
|
||||
protocol = 'https' if '-ssl' in r else 'http'
|
||||
@@ -320,15 +342,15 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
logger.warning('Can not resolve SRV for %s', srv)
|
||||
return list(set(ret))
|
||||
|
||||
def _get_machines_cache_from_dns(self, host, port):
|
||||
def _get_machines_cache_from_dns(self, host: str, port: int) -> List[str]:
|
||||
"""One host might be resolved into multiple ip addresses. We will make list out of it"""
|
||||
if self.protocol == 'http':
|
||||
ret = map(lambda res: uri(self.protocol, res[-1][:2]), self._dns_resolver.resolve(host, port))
|
||||
ret = [uri(self.protocol, res[-1][:2]) for res in self._dns_resolver.resolve(host, port)]
|
||||
if ret:
|
||||
return list(set(ret))
|
||||
return [uri(self.protocol, (host, port))]
|
||||
|
||||
def _get_machines_cache_from_config(self):
|
||||
def _get_machines_cache_from_config(self) -> List[str]:
|
||||
if 'proxy' in self._config:
|
||||
return [uri(self.protocol, (self._config['host'], self._config['port']))]
|
||||
|
||||
@@ -344,13 +366,14 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
return machines_cache
|
||||
|
||||
@staticmethod
|
||||
def _update_dns_cache(func, machines):
|
||||
def _update_dns_cache(func: Callable[[str, int], None], machines: List[str]) -> None:
|
||||
for url in machines:
|
||||
r = urlparse(url)
|
||||
port = r.port or (443 if r.scheme == 'https' else 80)
|
||||
func(r.hostname, port)
|
||||
if r.hostname:
|
||||
port = r.port or (443 if r.scheme == 'https' else 80)
|
||||
func(r.hostname, port)
|
||||
|
||||
def _load_machines_cache(self):
|
||||
def _load_machines_cache(self) -> bool:
|
||||
"""This method should fill up `_machines_cache` from scratch.
|
||||
It could happen only in two cases:
|
||||
1. During class initialization
|
||||
@@ -369,38 +392,48 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
# enforce resolving dns name,they might get new ips
|
||||
self._update_dns_cache(self._dns_resolver.remove, machines_cache)
|
||||
|
||||
# The etcd cluster could change its topology over time and depending on how we resolve the initial
|
||||
# topology (list of hosts in the Patroni config or DNS records, A or SRV) we might get into the situation
|
||||
# the the real topology doesn't match anymore with the topology resolved from the configuration file.
|
||||
# In case if the "initial" topology is the same as before we will not override the `_machines_cache`.
|
||||
ret = set(machines_cache) != set(self._initial_machines_cache)
|
||||
if ret:
|
||||
self._initial_machines_cache = self._machines_cache = machines_cache
|
||||
|
||||
# After filling up the initial list of machines_cache we should ask etcd-cluster about actual list
|
||||
self._refresh_machines_cache(True)
|
||||
# after filling up the initial list of machines_cache we should ask etcd-cluster about actual list
|
||||
ret = self._refresh_machines_cache(machines_cache)
|
||||
|
||||
self._update_machines_cache = False
|
||||
return ret
|
||||
|
||||
def _refresh_machines_cache(self, updating_cache=False):
|
||||
def _refresh_machines_cache(self, machines_cache: Optional[List[str]] = None) -> bool:
|
||||
"""Get etcd cluster topology using Etcd API and put it to self._machines_cache
|
||||
|
||||
:param machines_cache: the list of nodes we want to run through executing API request
|
||||
in addition to values stored in the self._machines_cache
|
||||
:returns: `True` if self._machines_cache was updated with new values
|
||||
:raises EtcdException: if failed to get topology and `machines_cache` was specified.
|
||||
|
||||
The self._machines_cache will not be updated if nodes from the list are
|
||||
not accessible or if they are not returning correct results."""
|
||||
|
||||
if self._use_proxies:
|
||||
self._machines_cache = self._get_machines_cache_from_config()
|
||||
value = self._get_machines_cache_from_config()
|
||||
else:
|
||||
try:
|
||||
self._machines_cache = self.machines
|
||||
# we want to go through the list obtained from the config file + last known health topology
|
||||
value = self._get_machines_list(list(set((machines_cache or []) + self.machines_cache)))
|
||||
except etcd.EtcdConnectionFailed:
|
||||
if updating_cache:
|
||||
raise etcd.EtcdException("Could not get the list of servers, "
|
||||
"maybe you provided the wrong "
|
||||
"host(s) to connect to?")
|
||||
return
|
||||
value = []
|
||||
|
||||
if value:
|
||||
ret = set(self._machines_cache) != set(value)
|
||||
self._machines_cache = value
|
||||
elif machines_cache: # we are just starting or all nodes were not available at some point
|
||||
raise etcd.EtcdException("Could not get the list of servers, "
|
||||
"maybe you provided the wrong "
|
||||
"host(s) to connect to?")
|
||||
else:
|
||||
return False
|
||||
|
||||
if self._base_uri not in self._machines_cache:
|
||||
self.set_base_uri(self._machines_cache[0])
|
||||
self._machines_cache_updated = time.time()
|
||||
return ret
|
||||
|
||||
def set_base_uri(self, value):
|
||||
def set_base_uri(self, value: str) -> None:
|
||||
if self._base_uri != value:
|
||||
logger.info('Selected new etcd server %s', value)
|
||||
self._base_uri = value
|
||||
@@ -410,22 +443,25 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
|
||||
ERROR_CLS = EtcdError
|
||||
|
||||
def __del__(self):
|
||||
if self.http is not None:
|
||||
try:
|
||||
self.http.clear()
|
||||
except (ReferenceError, TypeError, AttributeError):
|
||||
pass
|
||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||
super(EtcdClient, self).__init__({**config, 'version_prefix': None}, dns_resolver, cache_ttl)
|
||||
|
||||
def _prepare_get_members(self, etcd_nodes):
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
self.http.clear()
|
||||
except (ReferenceError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
|
||||
return self._prepare_common_parameters(etcd_nodes)
|
||||
|
||||
def _get_members(self, base_uri, **kwargs):
|
||||
def _get_members(self, base_uri: str, **kwargs: Any) -> List[str]:
|
||||
response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs)
|
||||
data = self._handle_server_response(response).data.decode('utf-8')
|
||||
return [m.strip() for m in data.split(',') if m.strip()]
|
||||
|
||||
def _prepare_request(self, kwargs, params=None, method=None):
|
||||
def _prepare_request(self, kwargs: Dict[str, Any], params: Optional[Dict[str, Any]] = None,
|
||||
method: Optional[str] = None) -> Callable[..., urllib3.response.HTTPResponse]:
|
||||
kwargs['fields'] = params
|
||||
if method in (self._MPOST, self._MPUT):
|
||||
kwargs['encode_multipart'] = False
|
||||
@@ -434,25 +470,32 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
|
||||
class AbstractEtcd(AbstractDCS):
|
||||
|
||||
def __init__(self, config, client_cls, retry_errors_cls):
|
||||
def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
|
||||
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
|
||||
super(AbstractEtcd, self).__init__(config)
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=retry_errors_cls)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
self._client = self.get_etcd_client(config, client_cls)
|
||||
self._abstract_client = self.get_etcd_client(config, client_cls)
|
||||
self.__do_not_watch = False
|
||||
self._has_failed = False
|
||||
|
||||
def reload_config(self, config):
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _client(self) -> AbstractEtcdClientWithFailover:
|
||||
"""return correct type of etcd client"""
|
||||
|
||||
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
|
||||
super(AbstractEtcd, self).reload_config(config)
|
||||
self._client.reload_config(config.get(self.__class__.__name__.lower(), {}))
|
||||
|
||||
def retry(self, *args, **kwargs):
|
||||
def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
retry = self._retry.copy()
|
||||
kwargs['retry'] = retry
|
||||
return retry(*args, **kwargs)
|
||||
return retry(method, *args, **kwargs)
|
||||
|
||||
def _handle_exception(self, e, name='', do_sleep=False, raise_ex=None):
|
||||
def _handle_exception(self, e: Exception, name: str = '', do_sleep: bool = False,
|
||||
raise_ex: Optional[Exception] = None) -> None:
|
||||
if not self._has_failed:
|
||||
logger.exception(name)
|
||||
else:
|
||||
@@ -463,7 +506,18 @@ class AbstractEtcd(AbstractDCS):
|
||||
if isinstance(raise_ex, Exception):
|
||||
raise raise_ex
|
||||
|
||||
def _run_and_handle_exceptions(self, method, *args, **kwargs):
|
||||
def handle_etcd_exceptions(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
retval = func(self, *args, **kwargs)
|
||||
self._has_failed = False
|
||||
return retval
|
||||
except (RetryFailedError, etcd.EtcdException) as e:
|
||||
self._handle_exception(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
|
||||
|
||||
def _run_and_handle_exceptions(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
retry = kwargs.pop('retry', self.retry)
|
||||
try:
|
||||
return retry(method, *args, **kwargs) if retry else method(*args, **kwargs)
|
||||
@@ -475,19 +529,20 @@ class AbstractEtcd(AbstractDCS):
|
||||
except Exception as e:
|
||||
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
|
||||
|
||||
@staticmethod
|
||||
def set_socket_options(sock, socket_options):
|
||||
def set_socket_options(self, sock: socket.socket,
|
||||
socket_options: Optional[Collection[Tuple[int, int, int]]]) -> None:
|
||||
if socket_options:
|
||||
for opt in socket_options:
|
||||
sock.setsockopt(*opt)
|
||||
|
||||
def get_etcd_client(self, config, client_cls):
|
||||
def get_etcd_client(self, config: Dict[str, Any],
|
||||
client_cls: Type[AbstractEtcdClientWithFailover]) -> AbstractEtcdClientWithFailover:
|
||||
config = deepcopy(config)
|
||||
if 'proxy' in config:
|
||||
config['use_proxies'] = True
|
||||
config['url'] = config['proxy']
|
||||
|
||||
if 'url' in config:
|
||||
if 'url' in config and isinstance(config['url'], str):
|
||||
r = urlparse(config['url'])
|
||||
config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379,
|
||||
'username': r.username, 'password': r.password})
|
||||
@@ -496,13 +551,14 @@ class AbstractEtcd(AbstractDCS):
|
||||
default_port = config.pop('port', 2379)
|
||||
protocol = config.get('protocol', 'http')
|
||||
|
||||
if isinstance(hosts, six.string_types):
|
||||
if isinstance(hosts, str):
|
||||
hosts = hosts.split(',')
|
||||
|
||||
config['hosts'] = []
|
||||
config_hosts: List[str] = []
|
||||
for value in hosts:
|
||||
if isinstance(value, six.string_types):
|
||||
config['hosts'].append(uri(protocol, split_host_port(value.strip(), default_port)))
|
||||
if isinstance(value, str):
|
||||
config_hosts.append(uri(protocol, split_host_port(value.strip(), default_port)))
|
||||
config['hosts'] = config_hosts
|
||||
elif 'host' in config:
|
||||
host, port = split_host_port(config['host'], 2379)
|
||||
config['host'] = host
|
||||
@@ -521,8 +577,10 @@ class AbstractEtcd(AbstractDCS):
|
||||
|
||||
dns_resolver = DnsCachingResolver()
|
||||
|
||||
def create_connection_patched(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
|
||||
source_address=None, socket_options=None):
|
||||
def create_connection_patched(
|
||||
address: Tuple[str, int], timeout: Any = object(),
|
||||
source_address: Optional[Any] = None, socket_options: Optional[Collection[Tuple[int, int, int]]] = None
|
||||
) -> socket.socket:
|
||||
host, port = address
|
||||
if host.startswith('['):
|
||||
host = host.strip('[]')
|
||||
@@ -532,7 +590,7 @@ class AbstractEtcd(AbstractDCS):
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
self.set_socket_options(sock, socket_options)
|
||||
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
|
||||
if timeout is None or isinstance(timeout, (float, int)):
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
@@ -563,51 +621,49 @@ class AbstractEtcd(AbstractDCS):
|
||||
time.sleep(5)
|
||||
return client
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
def set_ttl(self, ttl: int) -> Optional[bool]:
|
||||
ttl = int(ttl)
|
||||
ret = self._ttl != ttl
|
||||
self._ttl = ttl
|
||||
self._client.set_machines_cache_ttl(ttl*10)
|
||||
self._client.set_machines_cache_ttl(ttl * 10)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
def ttl(self) -> int:
|
||||
return self._ttl
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
def set_retry_timeout(self, retry_timeout: int) -> None:
|
||||
self._retry.deadline = retry_timeout
|
||||
self._client.set_read_timeout(retry_timeout)
|
||||
|
||||
|
||||
def catch_etcd_errors(func):
|
||||
def wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
retval = func(self, *args, **kwargs) is not None
|
||||
self._has_failed = False
|
||||
return retval
|
||||
except (RetryFailedError, etcd.EtcdException) as e:
|
||||
self._handle_exception(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
|
||||
|
||||
def catch_etcd_errors(func: Callable[..., Any]) -> Any:
|
||||
def wrapper(self: AbstractEtcd, *args: Any, **kwargs: Any) -> Any:
|
||||
return self.handle_etcd_exceptions(func, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class Etcd(AbstractEtcd):
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
||||
self.__do_not_watch = False
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
@property
|
||||
def _client(self) -> EtcdClient:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(self._abstract_client, EtcdClient)
|
||||
return self._abstract_client
|
||||
|
||||
def set_ttl(self, ttl: int) -> Optional[bool]:
|
||||
self.__do_not_watch = super(Etcd, self).set_ttl(ttl)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def member(node):
|
||||
def member(node: etcd.EtcdResult) -> Member:
|
||||
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
|
||||
|
||||
def _cluster_from_nodes(self, etcd_index, nodes):
|
||||
def _cluster_from_nodes(self, etcd_index: int, nodes: Dict[str, etcd.EtcdResult]) -> Cluster:
|
||||
# get initialize flag
|
||||
initialize = nodes.get(self._INITIALIZE)
|
||||
initialize = initialize and initialize.value
|
||||
@@ -621,23 +677,8 @@ class Etcd(AbstractEtcd):
|
||||
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
|
||||
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status.value)
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn.value
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status.value)
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -647,8 +688,8 @@ class Etcd(AbstractEtcd):
|
||||
if leader:
|
||||
member = Member(-1, leader.value, None, {})
|
||||
member = ([m for m in members if m.name == leader.value] or [member])[0]
|
||||
index = etcd_index if etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1
|
||||
leader = Leader(index, leader.ttl, member)
|
||||
version = etcd_index if etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1
|
||||
leader = Leader(version, leader.ttl, member)
|
||||
|
||||
# failover key
|
||||
failover = nodes.get(self._FAILOVER)
|
||||
@@ -666,43 +707,52 @@ class Etcd(AbstractEtcd):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path):
|
||||
result = self.retry(self._client.read, path, recursive=True)
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
try:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return Cluster.empty()
|
||||
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
|
||||
return self._cluster_from_nodes(result.etcd_index, nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path):
|
||||
clusters = defaultdict(dict)
|
||||
result = self.retry(self._client.read, path, recursive=True)
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
try:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return {}
|
||||
|
||||
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
|
||||
for node in result.leaves:
|
||||
key = node.key[len(result.key):].lstrip('/').split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
|
||||
|
||||
def _load_cluster(self, path, loader):
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
cluster = None
|
||||
try:
|
||||
cluster = loader(path)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
cluster = Cluster(None, None, None, None, [], None, None, None, None, None)
|
||||
except Exception as e:
|
||||
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
|
||||
self._has_failed = False
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert cluster is not None
|
||||
return cluster
|
||||
|
||||
@catch_etcd_errors
|
||||
def touch_member(self, data):
|
||||
data = json.dumps(data, separators=(',', ':'))
|
||||
return self._client.set(self.member_path, data, self._ttl)
|
||||
def touch_member(self, data: Dict[str, Any]) -> bool:
|
||||
value = json.dumps(data, separators=(',', ':'))
|
||||
return bool(self._client.set(self.member_path, value, self._ttl))
|
||||
|
||||
@catch_etcd_errors
|
||||
def take_leader(self):
|
||||
def take_leader(self) -> bool:
|
||||
return self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl)
|
||||
|
||||
def _do_attempt_to_acquire_leader(self):
|
||||
def _do_attempt_to_acquire_leader(self) -> bool:
|
||||
try:
|
||||
return bool(self.retry(self._client.write, self.leader_path, self._name, ttl=self._ttl, prevExist=False))
|
||||
except etcd.EtcdAlreadyExist:
|
||||
@@ -710,26 +760,26 @@ class Etcd(AbstractEtcd):
|
||||
return False
|
||||
|
||||
@catch_return_false_exception
|
||||
def attempt_to_acquire_leader(self):
|
||||
def attempt_to_acquire_leader(self) -> bool:
|
||||
return self._run_and_handle_exceptions(self._do_attempt_to_acquire_leader, retry=None)
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._client.write(self.failover_path, value, prevIndex=index or 0)
|
||||
def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return bool(self._client.write(self.failover_path, value, prevIndex=version or 0))
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_config_value(self, value, index=None):
|
||||
return self._client.write(self.config_path, value, prevIndex=index or 0)
|
||||
def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return bool(self._client.write(self.config_path, value, prevIndex=version or 0))
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.set(self.leader_optime_path, last_lsn)
|
||||
def _write_leader_optime(self, last_lsn: str) -> bool:
|
||||
return bool(self._client.set(self.leader_optime_path, last_lsn))
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.set(self.status_path, value)
|
||||
def _write_status(self, value: str) -> bool:
|
||||
return bool(self._client.set(self.status_path, value))
|
||||
|
||||
def _do_update_leader(self):
|
||||
def _do_update_leader(self) -> bool:
|
||||
try:
|
||||
return self.retry(self._client.write, self.leader_path, self._name,
|
||||
prevValue=self._name, ttl=self._ttl) is not None
|
||||
@@ -737,52 +787,52 @@ class Etcd(AbstractEtcd):
|
||||
return self._do_attempt_to_acquire_leader()
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_failsafe(self, value):
|
||||
return self._client.set(self.failsafe_path, value)
|
||||
def _write_failsafe(self, value: str) -> bool:
|
||||
return bool(self._client.set(self.failsafe_path, value))
|
||||
|
||||
@catch_return_false_exception
|
||||
def _update_leader(self):
|
||||
return self._run_and_handle_exceptions(self._do_update_leader, retry=None)
|
||||
def _update_leader(self, leader: Leader) -> bool:
|
||||
return bool(self._run_and_handle_exceptions(self._do_update_leader, retry=None))
|
||||
|
||||
@catch_etcd_errors
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
return self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new))
|
||||
def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
|
||||
return bool(self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new)))
|
||||
|
||||
@catch_etcd_errors
|
||||
def _delete_leader(self):
|
||||
return self._client.delete(self.leader_path, prevValue=self._name)
|
||||
def _delete_leader(self, leader: Leader) -> bool:
|
||||
return bool(self._client.delete(self.leader_path, prevValue=self._name))
|
||||
|
||||
@catch_etcd_errors
|
||||
def cancel_initialization(self):
|
||||
return self.retry(self._client.delete, self.initialize_path)
|
||||
def cancel_initialization(self) -> bool:
|
||||
return bool(self.retry(self._client.delete, self.initialize_path))
|
||||
|
||||
@catch_etcd_errors
|
||||
def delete_cluster(self):
|
||||
return self.retry(self._client.delete, self.client_path(''), recursive=True)
|
||||
def delete_cluster(self) -> bool:
|
||||
return bool(self.retry(self._client.delete, self.client_path(''), recursive=True))
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_history_value(self, value):
|
||||
return self._client.write(self.history_path, value)
|
||||
def set_history_value(self, value: str) -> bool:
|
||||
return bool(self._client.write(self.history_path, value))
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
return self.retry(self._client.write, self.sync_path, value, prevIndex=index or 0)
|
||||
def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
|
||||
return self.retry(self._client.write, self.sync_path, value, prevIndex=version or 0).modifiedIndex
|
||||
|
||||
@catch_etcd_errors
|
||||
def delete_sync_state(self, index=None):
|
||||
return self.retry(self._client.delete, self.sync_path, prevIndex=index or 0)
|
||||
def delete_sync_state(self, version: Optional[int] = None) -> bool:
|
||||
return bool(self.retry(self._client.delete, self.sync_path, prevIndex=version or 0))
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
|
||||
if self.__do_not_watch:
|
||||
self.__do_not_watch = False
|
||||
return True
|
||||
|
||||
if leader_index:
|
||||
if leader_version:
|
||||
end_time = time.time() + timeout
|
||||
|
||||
while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
|
||||
try:
|
||||
result = self._client.watch(self.leader_path, index=leader_index, timeout=timeout + 0.5)
|
||||
result = self._client.watch(self.leader_path, index=leader_version, timeout=timeout + 0.5)
|
||||
self._has_failed = False
|
||||
if result.action == 'compareAndSwap':
|
||||
time.sleep(0.01)
|
||||
|
||||
+316
-241
File diff suppressed because it is too large
Load Diff
+19
-14
@@ -3,9 +3,12 @@ import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from patroni.dcs.zookeeper import ZooKeeper
|
||||
from patroni.request import get as requests_get
|
||||
from patroni.utils import uri
|
||||
from typing import Any, Callable, Dict, List, Union
|
||||
|
||||
from . import Cluster
|
||||
from .zookeeper import ZooKeeper
|
||||
from ..request import get as requests_get
|
||||
from ..utils import uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,11 +17,12 @@ class ExhibitorEnsembleProvider(object):
|
||||
|
||||
TIMEOUT = 3.1
|
||||
|
||||
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300):
|
||||
def __init__(self, hosts: List[str], port: int,
|
||||
uri_path: str = '/exhibitor/v1/cluster/list', poll_interval: int = 300) -> None:
|
||||
self._exhibitor_port = port
|
||||
self._uri_path = uri_path
|
||||
self._poll_interval = poll_interval
|
||||
self._exhibitors = hosts
|
||||
self._exhibitors: List[str] = hosts
|
||||
self._boot_exhibitors = hosts
|
||||
self._zookeeper_hosts = ''
|
||||
self._next_poll = None
|
||||
@@ -26,7 +30,7 @@ class ExhibitorEnsembleProvider(object):
|
||||
logger.info('waiting on exhibitor')
|
||||
time.sleep(5)
|
||||
|
||||
def poll(self):
|
||||
def poll(self) -> bool:
|
||||
if self._next_poll and self._next_poll > time.time():
|
||||
return False
|
||||
|
||||
@@ -36,7 +40,8 @@ class ExhibitorEnsembleProvider(object):
|
||||
|
||||
if isinstance(json, dict) and 'servers' in json and 'port' in json:
|
||||
self._next_poll = time.time() + self._poll_interval
|
||||
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])])
|
||||
servers: List[str] = json['servers']
|
||||
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(servers)])
|
||||
if self._zookeeper_hosts != zookeeper_hosts:
|
||||
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
|
||||
self._zookeeper_hosts = zookeeper_hosts
|
||||
@@ -44,7 +49,7 @@ class ExhibitorEnsembleProvider(object):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _query_exhibitors(self, exhibitors):
|
||||
def _query_exhibitors(self, exhibitors: List[str]) -> Union[Dict[str, Any], Any]:
|
||||
random.shuffle(exhibitors)
|
||||
for host in exhibitors:
|
||||
try:
|
||||
@@ -55,20 +60,20 @@ class ExhibitorEnsembleProvider(object):
|
||||
return None
|
||||
|
||||
@property
|
||||
def zookeeper_hosts(self):
|
||||
def zookeeper_hosts(self) -> str:
|
||||
return self._zookeeper_hosts
|
||||
|
||||
|
||||
class Exhibitor(ZooKeeper):
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
interval = config.get('poll_interval', 300)
|
||||
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
|
||||
config = config.copy()
|
||||
config['hosts'] = self._ensemble_provider.zookeeper_hosts
|
||||
super(Exhibitor, self).__init__(config)
|
||||
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
|
||||
|
||||
def _load_cluster(self, path, loader):
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
if self._ensemble_provider.poll():
|
||||
self._client.set_hosts(self._ensemble_provider.zookeeper_hosts)
|
||||
return super(Exhibitor, self)._load_cluster(path, loader)
|
||||
|
||||
+385
-251
File diff suppressed because it is too large
Load Diff
+110
-109
@@ -10,10 +10,14 @@ from pysyncobj.dns_resolver import globalDnsResolver
|
||||
from pysyncobj.node import TCPNode
|
||||
from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.utility import TcpUtility
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import validate_directory
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,11 +28,12 @@ class RaftError(DCSError):
|
||||
|
||||
class _TCPTransport(TCPTransport):
|
||||
|
||||
def __init__(self, syncObj, selfNode, otherNodes):
|
||||
def __init__(self, syncObj: 'DynMemberSyncObj', selfNode: Optional[TCPNode],
|
||||
otherNodes: Collection[TCPNode]) -> None:
|
||||
super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes)
|
||||
self.setOnUtilityMessageCallback('members', syncObj.getMembers)
|
||||
|
||||
def _connectIfNecessarySingle(self, node):
|
||||
def _connectIfNecessarySingle(self, node: TCPNode) -> bool:
|
||||
try:
|
||||
return super(_TCPTransport, self)._connectIfNecessarySingle(node)
|
||||
except Exception as e:
|
||||
@@ -36,7 +41,7 @@ class _TCPTransport(TCPTransport):
|
||||
return False
|
||||
|
||||
|
||||
def resolve_host(self):
|
||||
def resolve_host(self: TCPNode) -> Optional[str]:
|
||||
return globalDnsResolver().resolve(self.host)
|
||||
|
||||
|
||||
@@ -45,17 +50,19 @@ setattr(TCPNode, 'ip', property(resolve_host))
|
||||
|
||||
class SyncObjUtility(object):
|
||||
|
||||
def __init__(self, otherNodes, conf, retry_timeout=10):
|
||||
def __init__(self, otherNodes: Collection[Union[str, TCPNode]], conf: SyncObjConf, retry_timeout: int = 10) -> None:
|
||||
self._nodes = otherNodes
|
||||
self._utility = TcpUtility(conf.password, retry_timeout/max(1, len(otherNodes)))
|
||||
self._utility = TcpUtility(conf.password, retry_timeout / max(1, len(otherNodes)))
|
||||
self.__node = next(iter(otherNodes), None)
|
||||
|
||||
def executeCommand(self, command):
|
||||
def executeCommand(self, command: List[Any]) -> Any:
|
||||
try:
|
||||
return self._utility.executeCommand(self.__node, command)
|
||||
if self.__node:
|
||||
return self._utility.executeCommand(self.__node, command)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def getMembers(self):
|
||||
def getMembers(self) -> Optional[List[str]]:
|
||||
for self.__node in self._nodes:
|
||||
response = self.executeCommand(['members'])
|
||||
if response:
|
||||
@@ -64,7 +71,8 @@ class SyncObjUtility(object):
|
||||
|
||||
class DynMemberSyncObj(SyncObj):
|
||||
|
||||
def __init__(self, selfAddress, partnerAddrs, conf, retry_timeout=10):
|
||||
def __init__(self, selfAddress: Optional[str], partnerAddrs: Collection[str],
|
||||
conf: SyncObjConf, retry_timeout: int = 10) -> None:
|
||||
self.__early_apply_local_log = selfAddress is not None
|
||||
self.applied_local_log = False
|
||||
|
||||
@@ -81,12 +89,12 @@ class DynMemberSyncObj(SyncObj):
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def getMembers(self, args, callback):
|
||||
def getMembers(self, args: Any, callback: Callable[[Any, Any], Any]) -> None:
|
||||
callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED
|
||||
if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes] +
|
||||
[{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
|
||||
if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes]
|
||||
+ [{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
|
||||
|
||||
def _onTick(self, timeToWait=0.0):
|
||||
def _onTick(self, timeToWait: float = 0.0):
|
||||
super(DynMemberSyncObj, self)._onTick(timeToWait)
|
||||
|
||||
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
|
||||
@@ -98,15 +106,16 @@ class DynMemberSyncObj(SyncObj):
|
||||
|
||||
class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
def __init__(self, on_ready, on_set, on_delete, **config):
|
||||
def __init__(self, on_ready: Optional[Callable[..., Any]], on_set: Optional[Callable[[str, Dict[str, Any]], None]],
|
||||
on_delete: Optional[Callable[[str], None]], **config: Any) -> None:
|
||||
self.__thread = None
|
||||
self.__on_set = on_set
|
||||
self.__on_delete = on_delete
|
||||
self.__limb = {}
|
||||
self.__limb: Dict[str, Dict[str, Any]] = {}
|
||||
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
|
||||
|
||||
self_addr = config.get('self_addr')
|
||||
partner_addrs = set(config.get('partner_addrs', []))
|
||||
partner_addrs: Set[str] = set(config.get('partner_addrs', []))
|
||||
if config.get('patronictl'):
|
||||
if self_addr:
|
||||
partner_addrs.add(self_addr)
|
||||
@@ -128,22 +137,22 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
onReady=on_ready, dynamicMembershipChange=True)
|
||||
|
||||
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf, self.__retry_timeout)
|
||||
self.__data = {}
|
||||
self.__data: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def __check_requirements(old_value, **kwargs):
|
||||
return ('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value)) and \
|
||||
('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue']) and \
|
||||
(not kwargs.get('prevIndex') or old_value and old_value['index'] == kwargs['prevIndex'])
|
||||
def __check_requirements(old_value: Dict[str, Any], **kwargs: Any) -> bool:
|
||||
return bool(('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value))
|
||||
and ('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue'])
|
||||
and (kwargs.get('prevIndex') is None or old_value and old_value['index'] == kwargs['prevIndex']))
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
def set_retry_timeout(self, retry_timeout: int) -> None:
|
||||
self.__retry_timeout = retry_timeout
|
||||
|
||||
def retry(self, func, *args, **kwargs):
|
||||
def retry(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
event = threading.Event()
|
||||
ret = {'result': None, 'error': -1}
|
||||
|
||||
def callback(result, error):
|
||||
def callback(result: Any, error: Any) -> None:
|
||||
ret.update(result=result, error=error)
|
||||
event.set()
|
||||
|
||||
@@ -167,7 +176,7 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
return False
|
||||
|
||||
@replicated
|
||||
def _set(self, key, value, **kwargs):
|
||||
def _set(self, key: str, value: Dict[str, Any], **kwargs: Any) -> Union[bool, Dict[str, Any]]:
|
||||
old_value = self.__data.get(key, {})
|
||||
if not self.__check_requirements(old_value, **kwargs):
|
||||
return False
|
||||
@@ -179,31 +188,32 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
self.__data[key] = value
|
||||
if self.__on_set:
|
||||
self.__on_set(key, value)
|
||||
return True
|
||||
return value
|
||||
|
||||
def set(self, key, value, ttl=None, handle_raft_error=True, **kwargs):
|
||||
def set(self, key: str, value: str, ttl: Optional[int] = None,
|
||||
handle_raft_error: bool = True, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
|
||||
old_value = self.__data.get(key, {})
|
||||
if not self.__check_requirements(old_value, **kwargs):
|
||||
return False
|
||||
|
||||
value = {'value': value, 'updated': time.time()}
|
||||
value['created'] = old_value.get('created', value['updated'])
|
||||
data: Dict[str, Any] = {'value': value, 'updated': time.time()}
|
||||
data['created'] = old_value.get('created', data['updated'])
|
||||
if ttl:
|
||||
value['expire'] = value['updated'] + ttl
|
||||
data['expire'] = data['updated'] + ttl
|
||||
try:
|
||||
return self.retry(self._set, key, value, **kwargs)
|
||||
return self.retry(self._set, key, data, **kwargs)
|
||||
except RaftError:
|
||||
if not handle_raft_error:
|
||||
raise
|
||||
return False
|
||||
|
||||
def __pop(self, key):
|
||||
def __pop(self, key: str) -> None:
|
||||
self.__data.pop(key)
|
||||
if self.__on_delete:
|
||||
self.__on_delete(key)
|
||||
|
||||
@replicated
|
||||
def _delete(self, key, recursive=False, **kwargs):
|
||||
def _delete(self, key: str, recursive: bool = False, **kwargs: Any) -> bool:
|
||||
if recursive:
|
||||
for k in list(self.__data.keys()):
|
||||
if k.startswith(key):
|
||||
@@ -214,7 +224,7 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
self.__pop(key)
|
||||
return True
|
||||
|
||||
def delete(self, key, recursive=False, **kwargs):
|
||||
def delete(self, key: str, recursive: bool = False, **kwargs: Any) -> bool:
|
||||
if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs):
|
||||
return False
|
||||
try:
|
||||
@@ -223,32 +233,32 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def __values_match(old, new):
|
||||
def __values_match(old: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
return all(old.get(n) == new.get(n) for n in ('created', 'updated', 'expire', 'value'))
|
||||
|
||||
@replicated
|
||||
def _expire(self, key, value, callback=None):
|
||||
def _expire(self, key: str, value: Dict[str, Any], callback: Optional[Callable[..., Any]] = None) -> None:
|
||||
current = self.__data.get(key)
|
||||
if current and self.__values_match(current, value):
|
||||
self.__pop(key)
|
||||
|
||||
def __expire_keys(self):
|
||||
def __expire_keys(self) -> None:
|
||||
for key, value in self.__data.items():
|
||||
if value and 'expire' in value and value['expire'] <= time.time() and \
|
||||
not (key in self.__limb and self.__values_match(self.__limb[key], value)):
|
||||
self.__limb[key] = value
|
||||
|
||||
def callback(*args):
|
||||
def callback(*args: Any) -> None:
|
||||
if key in self.__limb and self.__values_match(self.__limb[key], value):
|
||||
self.__limb.pop(key)
|
||||
self._expire(key, value, callback=callback)
|
||||
|
||||
def get(self, key, recursive=False):
|
||||
def get(self, key: str, recursive: bool = False) -> Union[None, Dict[str, Any], Dict[str, Dict[str, Any]]]:
|
||||
if not recursive:
|
||||
return self.__data.get(key)
|
||||
return {k: v for k, v in self.__data.items() if k.startswith(key)}
|
||||
|
||||
def _onTick(self, timeToWait=0.0):
|
||||
def _onTick(self, timeToWait: float = 0.0) -> None:
|
||||
super(KVStoreTTL, self)._onTick(timeToWait)
|
||||
|
||||
if self._isLeader():
|
||||
@@ -256,17 +266,17 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
else:
|
||||
self.__limb.clear()
|
||||
|
||||
def _autoTickThread(self):
|
||||
def _autoTickThread(self) -> None:
|
||||
self.__destroying = False
|
||||
while not self.__destroying:
|
||||
self.doTick(self.conf.autoTickPeriod)
|
||||
|
||||
def startAutoTick(self):
|
||||
def startAutoTick(self) -> None:
|
||||
self.__thread = threading.Thread(target=self._autoTickThread)
|
||||
self.__thread.daemon = True
|
||||
self.__thread.start()
|
||||
|
||||
def destroy(self):
|
||||
def destroy(self) -> None:
|
||||
if self.__thread:
|
||||
self.__destroying = True
|
||||
self.__thread.join()
|
||||
@@ -275,7 +285,7 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
class Raft(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Raft, self).__init__(config)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
|
||||
@@ -290,7 +300,7 @@ class Raft(AbstractDCS):
|
||||
else:
|
||||
logger.info('waiting on raft')
|
||||
|
||||
def _on_set(self, key, value):
|
||||
def _on_set(self, key: str, value: Dict[str, Any]) -> None:
|
||||
leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
|
||||
if key == value['created'] == value['updated'] and \
|
||||
(key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \
|
||||
@@ -298,29 +308,29 @@ class Raft(AbstractDCS):
|
||||
key in (self.config_path, self.sync_path):
|
||||
self.event.set()
|
||||
|
||||
def _on_delete(self, key):
|
||||
def _on_delete(self, key: str) -> None:
|
||||
if key == self.leader_path:
|
||||
self.event.set()
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
def set_ttl(self, ttl: int) -> Optional[bool]:
|
||||
self._ttl = ttl
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
def ttl(self) -> int:
|
||||
return self._ttl
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
def set_retry_timeout(self, retry_timeout: int) -> None:
|
||||
self._sync_obj.set_retry_timeout(retry_timeout)
|
||||
|
||||
def reload_config(self, config):
|
||||
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
|
||||
super(Raft, self).reload_config(config)
|
||||
globalDnsResolver().setTimeouts(self.ttl, self.loop_wait)
|
||||
|
||||
@staticmethod
|
||||
def member(key, value):
|
||||
def member(key: str, value: Dict[str, Any]) -> Member:
|
||||
return Member.from_node(value['index'], os.path.basename(key), None, value['value'])
|
||||
|
||||
def _cluster_from_nodes(self, nodes):
|
||||
def _cluster_from_nodes(self, nodes: Dict[str, Any]) -> Cluster:
|
||||
# get initialize flag
|
||||
initialize = nodes.get(self._INITIALIZE)
|
||||
initialize = initialize and initialize['value']
|
||||
@@ -334,23 +344,8 @@ class Raft(AbstractDCS):
|
||||
history = history and TimelineHistory.from_node(history['index'], history['value'])
|
||||
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status['value'])
|
||||
|
||||
# get list of members
|
||||
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -378,82 +373,88 @@ class Raft(AbstractDCS):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path):
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
if not response:
|
||||
return Cluster(None, None, None, None, [], None, None, None, None, None)
|
||||
return Cluster.empty()
|
||||
nodes = {key[len(path):]: value for key, value in response.items()}
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path):
|
||||
clusters = defaultdict(dict)
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
for key, value in response.items():
|
||||
for key, value in (response or {}).items():
|
||||
key = key[len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = value
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
|
||||
def _load_cluster(self, path, loader):
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
return loader(path)
|
||||
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1)
|
||||
def _write_leader_optime(self, last_lsn: str) -> bool:
|
||||
return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1) is not False
|
||||
|
||||
def _write_status(self, value):
|
||||
return self._sync_obj.set(self.status_path, value, timeout=1)
|
||||
def _write_status(self, value: str) -> bool:
|
||||
return self._sync_obj.set(self.status_path, value, timeout=1) is not False
|
||||
|
||||
def _write_failsafe(self, value):
|
||||
return self._sync_obj.set(self.failsafe_path, value, timeout=1)
|
||||
def _write_failsafe(self, value: str) -> bool:
|
||||
return self._sync_obj.set(self.failsafe_path, value, timeout=1) is not False
|
||||
|
||||
def _update_leader(self):
|
||||
def _update_leader(self, leader: Leader) -> bool:
|
||||
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
|
||||
handle_raft_error=False, prevValue=self._name)
|
||||
handle_raft_error=False, prevValue=self._name) is not False
|
||||
if not ret and self._sync_obj.get(self.leader_path) is None:
|
||||
ret = self.attempt_to_acquire_leader()
|
||||
return ret
|
||||
|
||||
def attempt_to_acquire_leader(self):
|
||||
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, handle_raft_error=False, prevExist=False)
|
||||
def attempt_to_acquire_leader(self) -> bool:
|
||||
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl,
|
||||
handle_raft_error=False, prevExist=False) is not False
|
||||
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._sync_obj.set(self.failover_path, value, prevIndex=index)
|
||||
def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return self._sync_obj.set(self.failover_path, value, prevIndex=version) is not False
|
||||
|
||||
def set_config_value(self, value, index=None):
|
||||
return self._sync_obj.set(self.config_path, value, prevIndex=index)
|
||||
def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return self._sync_obj.set(self.config_path, value, prevIndex=version) is not False
|
||||
|
||||
def touch_member(self, data):
|
||||
data = json.dumps(data, separators=(',', ':'))
|
||||
return self._sync_obj.set(self.member_path, data, self._ttl, timeout=2)
|
||||
def touch_member(self, data: Dict[str, Any]) -> bool:
|
||||
value = json.dumps(data, separators=(',', ':'))
|
||||
return self._sync_obj.set(self.member_path, value, self._ttl, timeout=2) is not False
|
||||
|
||||
def take_leader(self):
|
||||
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl)
|
||||
def take_leader(self) -> bool:
|
||||
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl) is not False
|
||||
|
||||
def initialize(self, create_new=True, sysid=''):
|
||||
return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new))
|
||||
def initialize(self, create_new: bool = True, sysid: str = '') -> bool:
|
||||
return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new)) is not False
|
||||
|
||||
def _delete_leader(self):
|
||||
def _delete_leader(self, leader: Leader) -> bool:
|
||||
return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1)
|
||||
|
||||
def cancel_initialization(self):
|
||||
def cancel_initialization(self) -> bool:
|
||||
return self._sync_obj.delete(self.initialize_path)
|
||||
|
||||
def delete_cluster(self):
|
||||
def delete_cluster(self) -> bool:
|
||||
return self._sync_obj.delete(self.client_path(''), recursive=True)
|
||||
|
||||
def set_history_value(self, value):
|
||||
return self._sync_obj.set(self.history_path, value)
|
||||
def set_history_value(self, value: str) -> bool:
|
||||
return self._sync_obj.set(self.history_path, value) is not False
|
||||
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
return self._sync_obj.set(self.sync_path, value, prevIndex=index)
|
||||
def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
|
||||
ret = self._sync_obj.set(self.sync_path, value, prevIndex=version)
|
||||
if isinstance(ret, dict):
|
||||
return ret['index']
|
||||
return ret
|
||||
|
||||
def delete_sync_state(self, index=None):
|
||||
return self._sync_obj.delete(self.sync_path, prevIndex=index)
|
||||
def delete_sync_state(self, version: Optional[int] = None) -> bool:
|
||||
return self._sync_obj.delete(self.sync_path, prevIndex=version)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
|
||||
try:
|
||||
return super(Raft, self).watch(leader_index, timeout)
|
||||
return super(Raft, self).watch(leader_version, timeout)
|
||||
finally:
|
||||
self.event.clear()
|
||||
|
||||
+145
-199
@@ -1,19 +1,23 @@
|
||||
import json
|
||||
import logging
|
||||
import select
|
||||
import six
|
||||
import socket
|
||||
import time
|
||||
|
||||
from kazoo.client import KazooClient, KazooState, KazooRetry
|
||||
from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from kazoo.protocol.states import KeeperState
|
||||
from kazoo.handlers.threading import AsyncResult, SequentialThreadingHandler
|
||||
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
|
||||
from kazoo.retry import RetryFailedError
|
||||
from kazoo.security import make_acl
|
||||
from kazoo.security import ACL, make_acl
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,14 +28,14 @@ class ZooKeeperError(DCSError):
|
||||
|
||||
class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
||||
|
||||
def __init__(self, connect_timeout):
|
||||
def __init__(self, connect_timeout: Union[int, float]) -> None:
|
||||
super(PatroniSequentialThreadingHandler, self).__init__()
|
||||
self.set_connect_timeout(connect_timeout)
|
||||
|
||||
def set_connect_timeout(self, connect_timeout):
|
||||
self._connect_timeout = max(1.0, connect_timeout/2.0) # try to connect to zookeeper node during loop_wait/2
|
||||
def set_connect_timeout(self, connect_timeout: Union[int, float]) -> None:
|
||||
self._connect_timeout = max(1.0, connect_timeout / 2.0) # try to connect to zookeeper node during loop_wait/2
|
||||
|
||||
def create_connection(self, *args, **kwargs):
|
||||
def create_connection(self, *args: Any, **kwargs: Any) -> socket.socket:
|
||||
"""This method is trying to establish connection with one of the zookeeper nodes.
|
||||
Somehow strategy "fail earlier and retry more often" works way better comparing to
|
||||
the original strategy "try to connect with specified timeout".
|
||||
@@ -42,16 +46,16 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
||||
:param args: always contains `tuple(host, port)` as the first element and could contain
|
||||
`connect_timeout` (negotiated session timeout) as the second element."""
|
||||
|
||||
args = list(args)
|
||||
if len(args) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method
|
||||
kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout*10)/10.0)
|
||||
elif len(args) == 1:
|
||||
args.append(self._connect_timeout)
|
||||
args_list: List[Any] = list(args)
|
||||
if len(args_list) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method
|
||||
kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout * 10) / 10.0)
|
||||
elif len(args_list) == 1:
|
||||
args_list.append(self._connect_timeout)
|
||||
else:
|
||||
args[1] = max(self._connect_timeout, args[1]/10.0)
|
||||
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs)
|
||||
args_list[1] = max(self._connect_timeout, args_list[1] / 10.0)
|
||||
return super(PatroniSequentialThreadingHandler, self).create_connection(*args_list, **kwargs)
|
||||
|
||||
def select(self, *args, **kwargs):
|
||||
def select(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""
|
||||
Python 3.XY may raise following exceptions if select/poll are called with an invalid socket:
|
||||
- `ValueError`: because fd == -1
|
||||
@@ -63,15 +67,13 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
||||
|
||||
try:
|
||||
return super(PatroniSequentialThreadingHandler, self).select(*args, **kwargs)
|
||||
except IOError as e:
|
||||
raise (select.error(e.errno, e.strerror) if six.PY2 else e)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise (e if six.PY2 and isinstance(e, TypeError) else select.error(9, str(e)))
|
||||
raise select.error(9, str(e))
|
||||
|
||||
|
||||
class PatroniKazooClient(KazooClient):
|
||||
|
||||
def _call(self, request, async_object):
|
||||
def _call(self, request: Tuple[Any], async_object: AsyncResult) -> Optional[bool]:
|
||||
# Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if
|
||||
# the connection is in the SUSPENDED state and Patroni was strongly relying on it.
|
||||
# The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued.
|
||||
@@ -85,10 +87,10 @@ class PatroniKazooClient(KazooClient):
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(ZooKeeper, self).__init__(config)
|
||||
|
||||
hosts = config.get('hosts', [])
|
||||
hosts: Union[str, List[str]] = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
hosts = ','.join(hosts)
|
||||
|
||||
@@ -97,34 +99,32 @@ class ZooKeeper(AbstractDCS):
|
||||
kwargs = {v: config[k] for k, v in mapping.items() if k in config}
|
||||
|
||||
if 'set_acls' in config:
|
||||
kwargs['default_acl'] = []
|
||||
default_acl: List[ACL] = []
|
||||
for principal, permissions in config['set_acls'].items():
|
||||
normalizedPermissions = [p.upper() for p in permissions]
|
||||
kwargs['default_acl'].append(make_acl(scheme='x509',
|
||||
credential=principal,
|
||||
read='READ' in normalizedPermissions,
|
||||
write='WRITE' in normalizedPermissions,
|
||||
create='CREATE' in normalizedPermissions,
|
||||
delete='DELETE' in normalizedPermissions,
|
||||
admin='ADMIN' in normalizedPermissions,
|
||||
all='ALL' in normalizedPermissions))
|
||||
default_acl.append(make_acl(scheme='x509',
|
||||
credential=principal,
|
||||
read='READ' in normalizedPermissions,
|
||||
write='WRITE' in normalizedPermissions,
|
||||
create='CREATE' in normalizedPermissions,
|
||||
delete='DELETE' in normalizedPermissions,
|
||||
admin='ADMIN' in normalizedPermissions,
|
||||
all='ALL' in normalizedPermissions))
|
||||
kwargs['default_acl'] = default_acl
|
||||
|
||||
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
|
||||
self._client.add_listener(self.session_listener)
|
||||
|
||||
self._fetch_cluster = True
|
||||
self._fetch_status = True
|
||||
self.__last_member_data = None
|
||||
self.__last_member_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
self._orig_kazoo_connect = self._client._connection._connect
|
||||
self._client._connection._connect = self._kazoo_connect
|
||||
|
||||
self._client.start()
|
||||
|
||||
def _kazoo_connect(self, *args):
|
||||
def _kazoo_connect(self, *args: Any) -> Tuple[Union[int, float], Union[int, float]]:
|
||||
"""Kazoo is using Ping's to determine health of connection to zookeeper. If there is no
|
||||
response on Ping after Ping interval (1/2 from read_timeout) it will consider current
|
||||
connection dead and try to connect to another node. Without this "magic" it was taking
|
||||
@@ -137,32 +137,21 @@ class ZooKeeper(AbstractDCS):
|
||||
`write_leader_optime()` methods, which also may hang..."""
|
||||
|
||||
ret = self._orig_kazoo_connect(*args)
|
||||
return max(self.loop_wait - 2, 2)*1000, ret[1]
|
||||
return max(self.loop_wait - 2, 2) * 1000, ret[1]
|
||||
|
||||
def session_listener(self, state):
|
||||
if state in [KazooState.SUSPENDED, KazooState.LOST]:
|
||||
self.cluster_watcher(None)
|
||||
def _watcher(self, event: WatchedEvent) -> None:
|
||||
if event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
|
||||
self.event.set()
|
||||
|
||||
def status_watcher(self, event):
|
||||
self._fetch_status = True
|
||||
self.event.set()
|
||||
|
||||
def cluster_watcher(self, event):
|
||||
self._fetch_cluster = True
|
||||
if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
|
||||
self.status_watcher(event)
|
||||
|
||||
def members_watcher(self, event):
|
||||
self._fetch_cluster = True
|
||||
|
||||
def reload_config(self, config):
|
||||
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
|
||||
loop_wait = config['loop_wait']
|
||||
|
||||
loop_wait_changed = self._loop_wait != loop_wait
|
||||
self._loop_wait = loop_wait
|
||||
self._client.handler.set_connect_timeout(loop_wait)
|
||||
if isinstance(self._client.handler, PatroniSequentialThreadingHandler):
|
||||
self._client.handler.set_connect_timeout(loop_wait)
|
||||
|
||||
# We need to reestablish connection to zookeeper if we want to change
|
||||
# read_timeout (and Ping interval respectively), because read_timeout
|
||||
@@ -173,7 +162,7 @@ class ZooKeeper(AbstractDCS):
|
||||
if not self.set_ttl(config['ttl']) and loop_wait_changed:
|
||||
self._client._connection._socket.close()
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
def set_ttl(self, ttl: int) -> Optional[bool]:
|
||||
"""It is not possible to change ttl (session_timeout) in zookeeper without
|
||||
destroying old session and creating the new one. This method returns `!True`
|
||||
if session_timeout has been changed (`restart()` has been called)."""
|
||||
@@ -184,152 +173,107 @@ class ZooKeeper(AbstractDCS):
|
||||
return True
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
return self._client._session_timeout / 1000.0
|
||||
def ttl(self) -> int:
|
||||
return int(self._client._session_timeout / 1000.0)
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
def set_retry_timeout(self, retry_timeout: int) -> None:
|
||||
retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry
|
||||
retry.deadline = retry_timeout
|
||||
|
||||
def get_node(self, key, watch=None):
|
||||
def get_node(
|
||||
self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None
|
||||
) -> Optional[Tuple[str, ZnodeStat]]:
|
||||
try:
|
||||
ret = self._client.get(key, watch)
|
||||
return (ret[0].decode('utf-8'), ret[1])
|
||||
except NoNodeError:
|
||||
return None
|
||||
|
||||
def get_status(self, path, leader):
|
||||
watch = self.status_watcher if not leader or leader.name != self._name else None
|
||||
|
||||
status = self.get_node(path + self._STATUS, watch)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status[0])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = self.get_node(path + self._LEADER_OPTIME, watch)
|
||||
last_lsn = last_lsn and last_lsn[0]
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
self._fetch_status = False
|
||||
return last_lsn, slots
|
||||
def get_status(self, path: str, leader: Optional[Leader]) -> Status:
|
||||
status = self.get_node(path + self._STATUS)
|
||||
if not status:
|
||||
status = self.get_node(path + self._LEADER_OPTIME)
|
||||
return Status.from_node(status and status[0])
|
||||
|
||||
@staticmethod
|
||||
def member(name, value, znode):
|
||||
def member(name: str, value: str, znode: ZnodeStat) -> Member:
|
||||
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
|
||||
|
||||
def get_children(self, key, watch=None):
|
||||
def get_children(self, key: str) -> List[str]:
|
||||
try:
|
||||
return self._client.get_children(key, watch)
|
||||
return self._client.get_children(key)
|
||||
except NoNodeError:
|
||||
return []
|
||||
|
||||
def load_members(self, path):
|
||||
members = []
|
||||
for member in self.get_children(path + self._MEMBERS, self.cluster_watcher):
|
||||
def load_members(self, path: str) -> List[Member]:
|
||||
members: List[Member] = []
|
||||
for member in self.get_children(path + self._MEMBERS):
|
||||
data = self.get_node(path + self._MEMBERS + member)
|
||||
if data is not None:
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
|
||||
def _cluster_loader(self, path):
|
||||
self._fetch_cluster = False
|
||||
self.event.clear()
|
||||
nodes = set(self.get_children(path, self.cluster_watcher))
|
||||
if not nodes:
|
||||
self._fetch_cluster = True
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
nodes = set(self.get_children(path))
|
||||
|
||||
# get initialize flag
|
||||
initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None
|
||||
|
||||
# get global dynamic configuration
|
||||
config = self.get_node(path + self._CONFIG, watch=self.cluster_watcher) if self._CONFIG in nodes else None
|
||||
config = self.get_node(path + self._CONFIG, watch=self._watcher) if self._CONFIG in nodes else None
|
||||
config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
|
||||
|
||||
# get timeline history
|
||||
history = self.get_node(path + self._HISTORY, watch=self.cluster_watcher) if self._HISTORY in nodes else None
|
||||
history = self.get_node(path + self._HISTORY) if self._HISTORY in nodes else None
|
||||
history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
|
||||
|
||||
# get synchronization state
|
||||
sync = self.get_node(path + self._SYNC, watch=self.cluster_watcher) if self._SYNC in nodes else None
|
||||
sync = self.get_node(path + self._SYNC) if self._SYNC in nodes else None
|
||||
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
|
||||
|
||||
# get list of members
|
||||
members = self.load_members(path) if self._MEMBERS[:-1] in nodes else []
|
||||
|
||||
# get leader
|
||||
leader = self.get_node(path + self._LEADER) if self._LEADER in nodes else None
|
||||
leader = self.get_node(path + self._LEADER, watch=self._watcher) if self._LEADER in nodes else None
|
||||
if leader:
|
||||
member = Member(-1, leader[0], None, {})
|
||||
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self._fetch_cluster = member.index == -1
|
||||
|
||||
# get last known leader lsn and slots
|
||||
last_lsn, slots = self.get_status(path, leader)
|
||||
status = self.get_status(path, leader)
|
||||
|
||||
# failover key
|
||||
failover = self.get_node(path + self._FAILOVER, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||
failover = self.get_node(path + self._FAILOVER) if self._FAILOVER in nodes else None
|
||||
failover = failover and Failover.from_node(failover[1].version, failover[0])
|
||||
|
||||
# get failsafe topology
|
||||
failsafe = self.get_node(path + self._FAILSAFE, watch=self.cluster_watcher) if self._FAILSAFE in nodes else None
|
||||
failsafe = self.get_node(path + self._FAILSAFE) if self._FAILSAFE in nodes else None
|
||||
try:
|
||||
failsafe = json.loads(failsafe[0]) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _citus_cluster_loader(self, path):
|
||||
fetch_cluster = False
|
||||
ret = {}
|
||||
for node in self.get_children(path, self.cluster_watcher):
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
ret: Dict[int, Cluster] = {}
|
||||
for node in self.get_children(path):
|
||||
if citus_group_re.match(node):
|
||||
ret[int(node)] = self._cluster_loader(path + node + '/')
|
||||
fetch_cluster = fetch_cluster or self._fetch_cluster
|
||||
self._fetch_cluster = fetch_cluster
|
||||
return ret
|
||||
|
||||
def _load_cluster(self, path, loader):
|
||||
cluster = self.cluster if path == self._base_path + '/' else None
|
||||
if self._fetch_cluster or cluster is None:
|
||||
try:
|
||||
cluster = self._client.retry(loader, path)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
self.cluster_watcher(None)
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
# The /status ZNode was updated or doesn't exist
|
||||
elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \
|
||||
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
|
||||
# If current node is the leader just clear the event without fetching anything (we are updating the /status)
|
||||
if cluster.leader and cluster.leader.name == self._name:
|
||||
self.event.clear()
|
||||
else:
|
||||
try:
|
||||
last_lsn, slots = self.get_status(self.client_path(''), cluster.leader)
|
||||
self.event.clear()
|
||||
cluster = list(cluster)
|
||||
cluster[3] = last_lsn
|
||||
cluster[8] = slots
|
||||
cluster = Cluster(*cluster)
|
||||
except Exception:
|
||||
pass
|
||||
return cluster
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
try:
|
||||
return self._client.retry(loader, path)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
|
||||
def _bypass_caches(self):
|
||||
self._fetch_cluster = True
|
||||
|
||||
def _create(self, path, value, retry=False, ephemeral=False):
|
||||
def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool:
|
||||
try:
|
||||
if retry:
|
||||
self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral)
|
||||
@@ -340,7 +284,7 @@ class ZooKeeper(AbstractDCS):
|
||||
logger.exception('Failed to create %s', path)
|
||||
return False
|
||||
|
||||
def attempt_to_acquire_leader(self):
|
||||
def attempt_to_acquire_leader(self) -> bool:
|
||||
try:
|
||||
self._client.retry(self._client.create, self.leader_path, self._name.encode('utf-8'),
|
||||
makepath=True, ephemeral=True)
|
||||
@@ -353,57 +297,56 @@ class ZooKeeper(AbstractDCS):
|
||||
logger.info('Could not take out TTL lock')
|
||||
return False
|
||||
|
||||
def _set_or_create(self, key, value, index=None, retry=False, do_not_create_empty=False):
|
||||
value = value.encode('utf-8')
|
||||
def _set_or_create(self, key: str, value: str, version: Optional[int] = None,
|
||||
retry: bool = False, do_not_create_empty: bool = False) -> Union[int, bool]:
|
||||
value_bytes = value.encode('utf-8')
|
||||
try:
|
||||
if retry:
|
||||
self._client.retry(self._client.set, key, value, version=index or -1)
|
||||
ret = self._client.retry(self._client.set, key, value_bytes, version=version or -1)
|
||||
else:
|
||||
self._client.set_async(key, value, version=index or -1).get(timeout=1)
|
||||
return True
|
||||
ret = self._client.set_async(key, value_bytes, version=version or -1).get(timeout=1)
|
||||
return ret.version
|
||||
except NoNodeError:
|
||||
if do_not_create_empty and not value:
|
||||
if do_not_create_empty and not value_bytes:
|
||||
return True
|
||||
elif index is None:
|
||||
return self._create(key, value, retry)
|
||||
elif version is None:
|
||||
if self._create(key, value_bytes, retry):
|
||||
return 0
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception('Failed to update %s', key)
|
||||
return False
|
||||
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._set_or_create(self.failover_path, value, index)
|
||||
def set_failover_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return self._set_or_create(self.failover_path, value, version) is not False
|
||||
|
||||
def set_config_value(self, value, index=None):
|
||||
return self._set_or_create(self.config_path, value, index, retry=True)
|
||||
def set_config_value(self, value: str, version: Optional[int] = None) -> bool:
|
||||
return self._set_or_create(self.config_path, value, version, retry=True) is not False
|
||||
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
sysid = sysid.encode('utf-8')
|
||||
return self._create(self.initialize_path, sysid, retry=True) if create_new \
|
||||
else self._client.retry(self._client.set, self.initialize_path, sysid)
|
||||
def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
|
||||
sysid_bytes = sysid.encode('utf-8')
|
||||
return self._create(self.initialize_path, sysid_bytes, retry=True) if create_new \
|
||||
else self._client.retry(self._client.set, self.initialize_path, sysid_bytes)
|
||||
|
||||
def touch_member(self, data):
|
||||
def touch_member(self, data: Dict[str, Any]) -> bool:
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
member_data = self.__last_member_data or member and member.data
|
||||
# We want to notify leader if some important fields in the member key changed by removing ZNode
|
||||
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or
|
||||
not (deep_compare(member_data.get('tags', {}), data.get('tags', {})) and
|
||||
(member_data.get('state') == data.get('state') or
|
||||
'running' not in (member_data.get('state'), data.get('state'))) and
|
||||
member_data.get('version') == data.get('version') and
|
||||
member_data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
member = None
|
||||
if member and member_data:
|
||||
# We want delete the member ZNode if our session doesn't match with session id on our member key
|
||||
if self._client.client_id is not None and member.session != self._client.client_id[0]:
|
||||
logger.warning('Recreating the member ZNode due to ownership mismatch')
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
member = None
|
||||
|
||||
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
|
||||
if member:
|
||||
if member and member_data:
|
||||
if deep_compare(data, member_data):
|
||||
return True
|
||||
else:
|
||||
@@ -424,22 +367,20 @@ class ZooKeeper(AbstractDCS):
|
||||
|
||||
return False
|
||||
|
||||
def take_leader(self):
|
||||
def take_leader(self) -> bool:
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._set_or_create(self.leader_optime_path, last_lsn)
|
||||
def _write_leader_optime(self, last_lsn: str) -> bool:
|
||||
return self._set_or_create(self.leader_optime_path, last_lsn) is not False
|
||||
|
||||
def _write_status(self, value):
|
||||
return self._set_or_create(self.status_path, value)
|
||||
def _write_status(self, value: str) -> bool:
|
||||
return self._set_or_create(self.status_path, value) is not False
|
||||
|
||||
def _write_failsafe(self, value):
|
||||
return self._set_or_create(self.failsafe_path, value)
|
||||
def _write_failsafe(self, value: str) -> bool:
|
||||
return self._set_or_create(self.failsafe_path, value) is not False
|
||||
|
||||
def _update_leader(self):
|
||||
cluster = self.cluster
|
||||
session = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
|
||||
if self._client.client_id and self._client.client_id[0] != session:
|
||||
def _update_leader(self, leader: Leader) -> bool:
|
||||
if self._client.client_id and self._client.client_id[0] != leader.session:
|
||||
logger.warning('Recreating the leader ZNode due to ownership mismatch')
|
||||
try:
|
||||
self._client.retry(self._client.delete, self.leader_path)
|
||||
@@ -461,38 +402,43 @@ class ZooKeeper(AbstractDCS):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _delete_leader(self):
|
||||
def _delete_leader(self, leader: Leader) -> bool:
|
||||
self._client.restart()
|
||||
return True
|
||||
|
||||
def _cancel_initialization(self):
|
||||
def _cancel_initialization(self) -> None:
|
||||
node = self.get_node(self.initialize_path)
|
||||
if node:
|
||||
self._client.delete(self.initialize_path, version=node[1].version)
|
||||
|
||||
def cancel_initialization(self):
|
||||
def cancel_initialization(self) -> bool:
|
||||
try:
|
||||
self._client.retry(self._cancel_initialization)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Unable to delete initialize key")
|
||||
return False
|
||||
|
||||
def delete_cluster(self):
|
||||
def delete_cluster(self) -> bool:
|
||||
try:
|
||||
return self._client.retry(self._client.delete, self.client_path(''), recursive=True)
|
||||
except NoNodeError:
|
||||
return True
|
||||
|
||||
def set_history_value(self, value):
|
||||
return self._set_or_create(self.history_path, value)
|
||||
def set_history_value(self, value: str) -> bool:
|
||||
return self._set_or_create(self.history_path, value) is not False
|
||||
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
return self._set_or_create(self.sync_path, value, index, retry=True, do_not_create_empty=True)
|
||||
def set_sync_state_value(self, value: str, version: Optional[int] = None) -> Union[int, bool]:
|
||||
return self._set_or_create(self.sync_path, value, version, retry=True, do_not_create_empty=True)
|
||||
|
||||
def delete_sync_state(self, index=None):
|
||||
return self.set_sync_state_value("{}", index)
|
||||
def delete_sync_state(self, version: Optional[int] = None) -> bool:
|
||||
return self.set_sync_state_value("{}", version) is not False
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
ret = super(ZooKeeper, self).watch(leader_index, timeout + 0.5)
|
||||
if ret and not self._fetch_status:
|
||||
self._fetch_cluster = True
|
||||
return ret or self._fetch_cluster
|
||||
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
|
||||
if leader_version:
|
||||
timeout += 0.5
|
||||
|
||||
try:
|
||||
return super(ZooKeeper, self).watch(leader_version, timeout)
|
||||
finally:
|
||||
self.event.clear()
|
||||
|
||||
+27
-9
@@ -1,37 +1,55 @@
|
||||
"""Implement high-level Patroni exceptions.
|
||||
|
||||
More specific exceptions can be found in other modules, as subclasses of any exception defined in this module.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PatroniException(Exception):
|
||||
"""Parent class for all kind of Patroni exceptions.
|
||||
|
||||
"""Parent class for all kind of exceptions related to selected distributed configuration store"""
|
||||
:ivar value: description of the exception.
|
||||
"""
|
||||
|
||||
def __init__(self, value):
|
||||
def __init__(self, value: Any) -> None:
|
||||
"""Create a new instance of :class:`PatroniException` with the given description.
|
||||
|
||||
:param value: description of the exception.
|
||||
"""
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
>>> str(PatroniException('foo'))
|
||||
"'foo'"
|
||||
"""
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class PatroniFatalException(PatroniException):
|
||||
"""Catastrophic exception that prevents Patroni from performing its job."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PostgresException(PatroniException):
|
||||
"""Any exception related with Postgres management."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DCSError(PatroniException):
|
||||
"""Parent class for all kind of DCS related exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PostgresConnectionException(PostgresException):
|
||||
"""Any problem faced while connecting to a Postgres instance."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WatchdogError(PatroniException):
|
||||
"""Any problem faced while managing a watchdog device."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigParseError(PatroniException):
|
||||
"""Any issue identified while loading or validating the Patroni configuration."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Helper object that helps with figuring out file and directory permissions based on permissions of PGDATA.
|
||||
|
||||
:var logger: logger of this module.
|
||||
:var pg_perm: instance of the :class:`__FilePermissions` object.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class __FilePermissions:
|
||||
"""Helper class for managing permissions of directories and files under PGDATA.
|
||||
|
||||
Execute :meth:`set_permissions_from_data_directory` to figure out which permissions should be used for files and
|
||||
directories under PGDATA based on permissions of PGDATA root directory.
|
||||
"""
|
||||
|
||||
# Mode mask for data directory permissions that only allows the owner to
|
||||
# read/write directories and files -- mask 077.
|
||||
__PG_MODE_MASK_OWNER = stat.S_IRWXG | stat.S_IRWXO
|
||||
|
||||
# Mode mask for data directory permissions that also allows group read/execute -- mask 027.
|
||||
__PG_MODE_MASK_GROUP = stat.S_IWGRP | stat.S_IRWXO
|
||||
|
||||
# Default mode for creating directories -- mode 700.
|
||||
__PG_DIR_MODE_OWNER = stat.S_IRWXU
|
||||
|
||||
# Mode for creating directories that allows group read/execute -- mode 750.
|
||||
__PG_DIR_MODE_GROUP = stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP
|
||||
|
||||
# Default mode for creating files -- mode 600.
|
||||
__PG_FILE_MODE_OWNER = stat.S_IRUSR | stat.S_IWUSR
|
||||
|
||||
# Mode for creating files that allows group read -- mode 640.
|
||||
__PG_FILE_MODE_GROUP = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create a :class:`__FilePermissions` object and set default permissions."""
|
||||
self.__set_owner_permissions()
|
||||
self.__set_umask()
|
||||
|
||||
def __set_umask(self) -> None:
|
||||
"""Set umask value based on calculations.
|
||||
|
||||
.. note::
|
||||
Should only be called once either :meth:`__set_owner_permissions`
|
||||
or :meth:`__set_group_permissions` has been executed.
|
||||
"""
|
||||
try:
|
||||
os.umask(self.__pg_mode_mask)
|
||||
except Exception as e:
|
||||
logger.error('Can not set umask to %03o: %r', self.__pg_mode_mask, e)
|
||||
|
||||
def __set_owner_permissions(self) -> None:
|
||||
"""Make directories/files accessible only by the owner."""
|
||||
self.__pg_dir_create_mode = self.__PG_DIR_MODE_OWNER
|
||||
self.__pg_file_create_mode = self.__PG_FILE_MODE_OWNER
|
||||
self.__pg_mode_mask = self.__PG_MODE_MASK_OWNER
|
||||
|
||||
def __set_group_permissions(self) -> None:
|
||||
"""Make directories/files accessible by the owner and readable by group."""
|
||||
self.__pg_dir_create_mode = self.__PG_DIR_MODE_GROUP
|
||||
self.__pg_file_create_mode = self.__PG_FILE_MODE_GROUP
|
||||
self.__pg_mode_mask = self.__PG_MODE_MASK_GROUP
|
||||
|
||||
def set_permissions_from_data_directory(self, data_dir: str) -> None:
|
||||
"""Set new permissions based on provided *data_dir*.
|
||||
|
||||
:param data_dir: reference to PGDATA to calculate permissions from.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(data_dir)
|
||||
if (st.st_mode & self.__PG_DIR_MODE_GROUP) == self.__PG_DIR_MODE_GROUP:
|
||||
self.__set_group_permissions()
|
||||
else:
|
||||
self.__set_owner_permissions()
|
||||
except Exception as e:
|
||||
logger.error('Can not check permissions on %s: %r', data_dir, e)
|
||||
else:
|
||||
self.__set_umask()
|
||||
|
||||
@property
|
||||
def dir_create_mode(self) -> int:
|
||||
"""Directory permissions."""
|
||||
return self.__pg_dir_create_mode
|
||||
|
||||
@property
|
||||
def file_create_mode(self) -> int:
|
||||
"""File permissions."""
|
||||
return self.__pg_file_create_mode
|
||||
|
||||
|
||||
pg_perm = __FilePermissions()
|
||||
+678
-446
File diff suppressed because it is too large
Load Diff
+192
-32
@@ -1,3 +1,8 @@
|
||||
"""Patroni logging facilities.
|
||||
|
||||
Daemon processes will use a 2-step logging handler. Whenever a log message is issued it is initially enqueued in-memory
|
||||
and is later asynchronously flushed by a thread to the final destination.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -5,41 +10,84 @@ import sys
|
||||
from copy import deepcopy
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from patroni.utils import deep_compare
|
||||
from six.moves.queue import Queue, Full
|
||||
from queue import Queue, Full
|
||||
from threading import Lock, Thread
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_exception(logger_obj, msg, *args, **kwargs):
|
||||
def debug_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
|
||||
"""Add full stack trace info to debug log messages and partial to others.
|
||||
|
||||
Handle :func:`~self.exception` calls for *self*.
|
||||
|
||||
.. note::
|
||||
* If *self* log level is set to ``DEBUG``, then issue a ``DEBUG`` message with the complete stack trace;
|
||||
* If *self* log level is ``INFO`` or higher, then issue an ``ERROR`` message with only the last line of
|
||||
the stack trace.
|
||||
|
||||
:param self: logger for which :func:`~self.exception` will be processed.
|
||||
:param msg: the message related to the exception to be logged.
|
||||
:param args: positional arguments to be passed to :func:`~self.debug` or :func:`~self.error`.
|
||||
:param kwargs: keyword arguments to be passed to :func:`~self.debug` or :func:`~self.error`.
|
||||
"""
|
||||
kwargs.pop("exc_info", False)
|
||||
if logger_obj.isEnabledFor(logging.DEBUG):
|
||||
logger_obj.debug(msg, *args, exc_info=True, **kwargs)
|
||||
if self.isEnabledFor(logging.DEBUG):
|
||||
self.debug(msg, *args, exc_info=True, **kwargs)
|
||||
else:
|
||||
msg = "{0}, DETAIL: '{1}'".format(msg, sys.exc_info()[1])
|
||||
logger_obj.error(msg, *args, exc_info=False, **kwargs)
|
||||
self.error(msg, *args, exc_info=False, **kwargs)
|
||||
|
||||
|
||||
def error_exception(logger_obj, msg, *args, **kwargs):
|
||||
def error_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any) -> None:
|
||||
"""Add full stack trace info to error messages.
|
||||
|
||||
Handle :func:`~self.exception` calls for *self*.
|
||||
|
||||
.. note::
|
||||
* By default issue an ``ERROR`` message with the complete stack trace. If you do not want to show the complete
|
||||
stack trace, call with ``exc_info=False``.
|
||||
|
||||
:param self: logger for which :func:`~self.exception` will be processed.
|
||||
:param msg: the message related to the exception to be logged.
|
||||
:param args: positional arguments to be passed to :func:`~self.error`.
|
||||
:param kwargs: keyword arguments to be passed to :func:`~self.error`.
|
||||
"""
|
||||
exc_info = kwargs.pop("exc_info", True)
|
||||
logger_obj.error(msg, *args, exc_info=exc_info, **kwargs)
|
||||
self.error(msg, *args, exc_info=exc_info, **kwargs)
|
||||
|
||||
|
||||
class QueueHandler(logging.Handler):
|
||||
"""Queue-based logging handler.
|
||||
|
||||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
self.queue = Queue()
|
||||
:ivar queue: queue to hold log messages that are pending to be flushed to the final destination.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Queue initialised and initial records_lost established."""
|
||||
super().__init__()
|
||||
self.queue: Queue[Union[logging.LogRecord, None]] = Queue()
|
||||
self._records_lost = 0
|
||||
|
||||
def _put_record(self, record):
|
||||
def _put_record(self, record: logging.LogRecord) -> None:
|
||||
"""Asynchronously enqueue a log record.
|
||||
|
||||
:param record: the record to be logged.
|
||||
"""
|
||||
self.format(record)
|
||||
record.msg = record.message
|
||||
record.args = None
|
||||
record.exc_info = None
|
||||
self.queue.put_nowait(record)
|
||||
|
||||
def _try_to_report_lost_records(self):
|
||||
def _try_to_report_lost_records(self) -> None:
|
||||
"""Report the number of log messages that have been lost and reset the counter.
|
||||
|
||||
.. note::
|
||||
It will issue an ``WARNING`` message in the logs with the number of lost log messages.
|
||||
"""
|
||||
if self._records_lost:
|
||||
try:
|
||||
record = _LOGGER.makeRecord(_LOGGER.name, logging.WARNING, __file__, 0,
|
||||
@@ -50,7 +98,15 @@ class QueueHandler(logging.Handler):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def emit(self, record):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Handle each log record that is emitted.
|
||||
|
||||
Call :func:`_put_record` to enqueue the emitted log record.
|
||||
|
||||
Also check if we have previously lost any log record, and if so, log a ``WARNING`` message.
|
||||
|
||||
:param record: the record that was emitted.
|
||||
"""
|
||||
try:
|
||||
self._put_record(record)
|
||||
self._try_to_report_lost_records()
|
||||
@@ -58,21 +114,60 @@ class QueueHandler(logging.Handler):
|
||||
self._records_lost += 1
|
||||
|
||||
@property
|
||||
def records_lost(self):
|
||||
def records_lost(self) -> int:
|
||||
"""Number of log messages that have been lost while the queue was full."""
|
||||
return self._records_lost
|
||||
|
||||
|
||||
class ProxyHandler(logging.Handler):
|
||||
"""Handle log records in place of pending log handlers.
|
||||
|
||||
def __init__(self, patroni_logger):
|
||||
logging.Handler.__init__(self)
|
||||
.. note::
|
||||
This is used to handle log messages while the logger thread has not started yet, in which case the queue-based
|
||||
handler is not yet started.
|
||||
|
||||
:ivar patroni_logger: the logger thread.
|
||||
"""
|
||||
|
||||
def __init__(self, patroni_logger: 'PatroniLogger') -> None:
|
||||
"""Create a new :class:`ProxyHandler` instance.
|
||||
|
||||
:param patroni_logger: the logger thread.
|
||||
"""
|
||||
super().__init__()
|
||||
self.patroni_logger = patroni_logger
|
||||
|
||||
def emit(self, record):
|
||||
self.patroni_logger.log_handler.handle(record)
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Emit each log record that is handled.
|
||||
|
||||
Will push the log record down to :func:`~logging.Handler.handle` method of the currently configured log handler.
|
||||
|
||||
:param record: the record that was emitted.
|
||||
"""
|
||||
if self.patroni_logger.log_handler is not None:
|
||||
self.patroni_logger.log_handler.handle(record)
|
||||
|
||||
|
||||
class PatroniLogger(Thread):
|
||||
"""Logging thread for the Patroni daemon process.
|
||||
|
||||
It is a 2-step logging approach. Any time a log message is issued it is initially enqueued in-memory, and then
|
||||
asynchronously flushed to the final destination by the logging thread.
|
||||
|
||||
.. seealso::
|
||||
:class:`QueueHandler`: object used for enqueueing messages in-memory.
|
||||
|
||||
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
|
||||
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
|
||||
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
|
||||
:cvar NORMAL_LOG_QUEUE_SIZE: expected number of log messages per HA loop when operating under a normal situation.
|
||||
:cvar DEFAULT_MAX_QUEUE_SIZE: default maximum queue size for holding a backlog of log messages that are pending
|
||||
to be flushed.
|
||||
:cvar LOGGING_BROKEN_EXIT_CODE: exit code to be used if it detects(``5``).
|
||||
|
||||
:ivar log_handler: log handler that is currently being used by the thread.
|
||||
:ivar log_handler_lock: lock used to modify ``log_handler``.
|
||||
"""
|
||||
|
||||
DEFAULT_LEVEL = 'INFO'
|
||||
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
|
||||
@@ -82,14 +177,24 @@ class PatroniLogger(Thread):
|
||||
DEFAULT_MAX_QUEUE_SIZE = 1000
|
||||
LOGGING_BROKEN_EXIT_CODE = 5
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Prepare logging queue and proxy handlers as they become ready during daemon startup.
|
||||
|
||||
.. note::
|
||||
While Patroni is starting up it keeps ``DEBUG`` log level, and writes log messages through a proxy handler.
|
||||
Once the logger thread is finally started, it switches from that proxy handler to the queue based logger,
|
||||
and applies the configured log settings. The switching is used to avoid that the logger thread prevents
|
||||
Patroni from shutting down if any issue occurs in the meantime until the thread is properly started.
|
||||
"""
|
||||
super(PatroniLogger, self).__init__()
|
||||
self._queue_handler = QueueHandler()
|
||||
self._root_logger = logging.getLogger()
|
||||
self._config = None
|
||||
self._config: Optional[Dict[str, Any]] = None
|
||||
self.log_handler = None
|
||||
self.log_handler_lock = Lock()
|
||||
self._old_handlers = []
|
||||
self._old_handlers: List[logging.Handler] = []
|
||||
# initially set log level to ``DEBUG`` while the logger thread has not started running yet. The daemon process
|
||||
# will later adjust all log related settings with what was provided through the user configuration file.
|
||||
self.reload_config({'level': 'DEBUG'})
|
||||
# We will switch to the QueueHandler only when thread was started.
|
||||
# This is necessary to protect from the cases when Patroni constructor
|
||||
@@ -97,26 +202,59 @@ class PatroniLogger(Thread):
|
||||
self._proxy_handler = ProxyHandler(self)
|
||||
self._root_logger.addHandler(self._proxy_handler)
|
||||
|
||||
def update_loggers(self):
|
||||
loggers = deepcopy(self._config.get('loggers') or {})
|
||||
def update_loggers(self, config: Dict[str, Any]) -> None:
|
||||
"""Configure custom loggers' log levels.
|
||||
|
||||
.. note::
|
||||
It creates logger objects that are not defined yet in the log manager.
|
||||
|
||||
:param config: :class:`dict` object with custom loggers configuration, is set either from:
|
||||
|
||||
* ``log.loggers`` section of Patroni configuration; or
|
||||
|
||||
* from the method that is trying to make sure that the node name
|
||||
isn't duplicated (to silence annoying ``urllib3`` WARNING's).
|
||||
|
||||
:Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
update_loggers({'urllib3.connectionpool': 'WARNING'})
|
||||
"""
|
||||
loggers = deepcopy(config)
|
||||
for name, logger in self._root_logger.manager.loggerDict.items():
|
||||
# ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only
|
||||
# in the ones that were defined
|
||||
if not isinstance(logger, logging.PlaceHolder):
|
||||
# if this logger is present in *config*, use the configured level, otherwise
|
||||
# use ``logging.NOTSET``, which means it will inherit the level
|
||||
# from any parent node up to the root for which log level is defined.
|
||||
level = loggers.pop(name, logging.NOTSET)
|
||||
logger.setLevel(level)
|
||||
|
||||
# define loggers that do not exist yet and set level as configured in the *config*
|
||||
for name, level in loggers.items():
|
||||
logger = self._root_logger.manager.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
def reload_config(self, config):
|
||||
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Apply log related configuration.
|
||||
|
||||
.. note::
|
||||
It is also able to deal with runtime configuration changes.
|
||||
|
||||
:param config: ``log`` section from Patroni configuration.
|
||||
"""
|
||||
if self._config is None or not deep_compare(self._config, config):
|
||||
with self._queue_handler.queue.mutex:
|
||||
self._queue_handler.queue.maxsize = config.get('max_queue_size', self.DEFAULT_MAX_QUEUE_SIZE)
|
||||
|
||||
self._root_logger.setLevel(config.get('level', PatroniLogger.DEFAULT_LEVEL))
|
||||
if config.get('traceback_level', PatroniLogger.DEFAULT_TRACEBACK_LEVEL).lower() == 'debug':
|
||||
# show stack traces only if ``log.traceback_level`` is ``DEBUG``
|
||||
logging.Logger.exception = debug_exception
|
||||
else:
|
||||
# show stack traces as ``ERROR`` log messages
|
||||
logging.Logger.exception = error_exception
|
||||
|
||||
new_handler = None
|
||||
@@ -124,7 +262,9 @@ class PatroniLogger(Thread):
|
||||
if not isinstance(self.log_handler, RotatingFileHandler):
|
||||
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
|
||||
handler = new_handler or self.log_handler
|
||||
handler.maxBytes = int(config.get('file_size', 25000000))
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(handler, RotatingFileHandler)
|
||||
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
|
||||
handler.backupCount = int(config.get('file_num', 4))
|
||||
else:
|
||||
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
|
||||
@@ -137,7 +277,7 @@ class PatroniLogger(Thread):
|
||||
olddateformat = (self._config or {}).get('dateformat') or None
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
|
||||
if oldlogformat != logformat or olddateformat != dateformat or new_handler:
|
||||
if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
|
||||
handler.setFormatter(logging.Formatter(logformat, dateformat))
|
||||
|
||||
if new_handler:
|
||||
@@ -147,9 +287,16 @@ class PatroniLogger(Thread):
|
||||
self.log_handler = new_handler
|
||||
|
||||
self._config = config.copy()
|
||||
self.update_loggers()
|
||||
self.update_loggers(config.get('loggers') or {})
|
||||
|
||||
def _close_old_handlers(self):
|
||||
def _close_old_handlers(self) -> None:
|
||||
"""Close old log handlers.
|
||||
|
||||
.. note::
|
||||
It is used to remove different handlers that were configured previous to a reload in the configuration,
|
||||
e.g. if we are switching from :class:`~logging.handlers.RotatingFileHandler` to
|
||||
class:`~logging.StreamHandler` and vice-versa.
|
||||
"""
|
||||
while True:
|
||||
with self.log_handler_lock:
|
||||
if not self._old_handlers:
|
||||
@@ -160,7 +307,11 @@ class PatroniLogger(Thread):
|
||||
except Exception:
|
||||
_LOGGER.exception('Failed to close the old log handler %s', handler)
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
"""Run logger's thread main loop.
|
||||
|
||||
Keep consuming log queue until requested to quit through ``None`` special log record.
|
||||
"""
|
||||
# switch to QueueHandler only when the thread was started
|
||||
with self.log_handler_lock:
|
||||
self._root_logger.addHandler(self._queue_handler)
|
||||
@@ -170,12 +321,17 @@ class PatroniLogger(Thread):
|
||||
|
||||
while True:
|
||||
self._close_old_handlers()
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert self.log_handler is not None
|
||||
|
||||
record = self._queue_handler.queue.get(True)
|
||||
# special message that indicates Patroni is shutting down
|
||||
if record is None:
|
||||
break
|
||||
|
||||
if self._root_logger.level == logging.INFO:
|
||||
# messages like ``Lock owner: postgresql0; I am postgresql1`` will be shown only when stream doesn't
|
||||
# look normal. This is used to reduce chattiness of Patroni logs.
|
||||
if record.msg.startswith('Lock owner: '):
|
||||
prev_record, record = record, None
|
||||
else:
|
||||
@@ -189,8 +345,10 @@ class PatroniLogger(Thread):
|
||||
|
||||
self._queue_handler.queue.task_done()
|
||||
|
||||
def shutdown(self):
|
||||
def shutdown(self) -> None:
|
||||
"""Shut down the logger thread."""
|
||||
try:
|
||||
# ``None`` is a special message indicating to queue handler that it should quit its main loop.
|
||||
self._queue_handler.queue.put_nowait(None)
|
||||
except Full: # Queue is full.
|
||||
# It seems that logging is not working, exiting with non-standard exit-code is the best we can do.
|
||||
@@ -199,9 +357,11 @@ class PatroniLogger(Thread):
|
||||
logging.shutdown()
|
||||
|
||||
@property
|
||||
def queue_size(self):
|
||||
def queue_size(self) -> int:
|
||||
"""Number of log records in the queue."""
|
||||
return self._queue_handler.queue.qsize()
|
||||
|
||||
@property
|
||||
def records_lost(self):
|
||||
def records_lost(self) -> int:
|
||||
"""Number of logging records that have been lost while the queue was full."""
|
||||
return self._queue_handler.records_lost
|
||||
|
||||
+493
-273
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+124
-32
@@ -4,53 +4,106 @@ import shlex
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from six import string_types
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from ..dcs import RemoteMember
|
||||
from ..async_executor import CriticalTask
|
||||
from ..dcs import Leader, Member, RemoteMember
|
||||
from ..psycopg import quote_ident, quote_literal
|
||||
from ..utils import deep_compare
|
||||
from ..utils import deep_compare, unquote
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from . import Postgresql
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Bootstrap(object):
|
||||
|
||||
def __init__(self, postgresql):
|
||||
def __init__(self, postgresql: 'Postgresql') -> None:
|
||||
self._postgresql = postgresql
|
||||
self._running_custom_bootstrap = False
|
||||
|
||||
@property
|
||||
def running_custom_bootstrap(self):
|
||||
def running_custom_bootstrap(self) -> bool:
|
||||
return self._running_custom_bootstrap
|
||||
|
||||
@property
|
||||
def keep_existing_recovery_conf(self):
|
||||
def keep_existing_recovery_conf(self) -> bool:
|
||||
return self._running_custom_bootstrap and self._keep_existing_recovery_conf
|
||||
|
||||
@staticmethod
|
||||
def process_user_options(tool, options, not_allowed_options, error_handler):
|
||||
user_options = []
|
||||
def process_user_options(tool: str,
|
||||
options: Union[Any, Dict[str, str], List[Union[str, Dict[str, Any]]]],
|
||||
not_allowed_options: Tuple[str, ...],
|
||||
error_handler: Callable[[str], None]) -> List[str]:
|
||||
"""Format *options* in a list or dictionary format into command line long form arguments.
|
||||
|
||||
def option_is_allowed(name):
|
||||
.. note::
|
||||
The format of the output of this method is to prepare arguments for use in the ``initdb``
|
||||
method of `self._postgres`.
|
||||
|
||||
:Example:
|
||||
|
||||
The *options* can be defined as a dictionary of key, values to be converted into arguments:
|
||||
>>> Bootstrap.process_user_options('foo', {'foo': 'bar'}, (), print)
|
||||
['--foo=bar']
|
||||
|
||||
Or as a list of single string arguments
|
||||
>>> Bootstrap.process_user_options('foo', ['yes'], (), print)
|
||||
['--yes']
|
||||
|
||||
Or as a list of key, value options
|
||||
>>> Bootstrap.process_user_options('foo', [{'foo': 'bar'}], (), print)
|
||||
['--foo=bar']
|
||||
|
||||
Or a combination of single and key, values
|
||||
>>> Bootstrap.process_user_options('foo', ['yes', {'foo': 'bar'}], (), print)
|
||||
['--yes', '--foo=bar']
|
||||
|
||||
Options that contain spaces are passed as is to ``subprocess.call``
|
||||
>>> Bootstrap.process_user_options('foo', [{'foo': 'bar baz'}], (), print)
|
||||
['--foo=bar baz']
|
||||
|
||||
Options that are quoted will be unquoted, so the quotes aren't interpreted
|
||||
literally by the postgres command
|
||||
>>> Bootstrap.process_user_options('foo', [{'foo': '"bar baz"'}], (), print)
|
||||
['--foo=bar baz']
|
||||
|
||||
.. note::
|
||||
The *error_handler* is called when any of these conditions are met:
|
||||
|
||||
* Key, value dictionaries in the list form contains multiple keys.
|
||||
* If a key is listed in *not_allowed_options*.
|
||||
* If the options list is not in the required structure.
|
||||
|
||||
:param tool: The name of the tool used in error reports to *error_handler*
|
||||
:param options: Options to parse as a list of key, values or single values, or a dictionary
|
||||
:param not_allowed_options: List of keys that cannot be used in the list of key, value formatted options
|
||||
:param error_handler: A function which will be called when an error condition is encountered
|
||||
:returns: List of long form arguments to pass to the named tool
|
||||
"""
|
||||
user_options: List[str] = []
|
||||
|
||||
def option_is_allowed(name: str) -> bool:
|
||||
ret = name not in not_allowed_options
|
||||
if not ret:
|
||||
error_handler('{0} option for {1} is not allowed'.format(name, tool))
|
||||
return ret
|
||||
|
||||
if isinstance(options, dict):
|
||||
for k, v in options.items():
|
||||
if k and v:
|
||||
user_options.append('--{0}={1}'.format(k, v))
|
||||
for key, val in options.items():
|
||||
if key and val:
|
||||
user_options.append('--{0}={1}'.format(key, unquote(val)))
|
||||
elif isinstance(options, list):
|
||||
for opt in options:
|
||||
if isinstance(opt, string_types) and option_is_allowed(opt):
|
||||
if isinstance(opt, str) and option_is_allowed(opt):
|
||||
user_options.append('--{0}'.format(opt))
|
||||
elif isinstance(opt, dict):
|
||||
keys = list(opt.keys())
|
||||
if len(keys) != 1 or not isinstance(opt[keys[0]], string_types) or not option_is_allowed(keys[0]):
|
||||
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]):
|
||||
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
|
||||
' and value should be a string'.format(tool, opt[keys[0]]))
|
||||
user_options.append('--{0}={1}'.format(keys[0], opt[keys[0]]))
|
||||
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
|
||||
else:
|
||||
error_handler('Error when parsing {0} option {1}: value should be string value'
|
||||
' or a single key-value pair'.format(tool, opt))
|
||||
@@ -58,11 +111,11 @@ class Bootstrap(object):
|
||||
error_handler('{0} options must be list or dict'.format(tool))
|
||||
return user_options
|
||||
|
||||
def _initdb(self, config):
|
||||
self._postgresql.set_state('initalizing new cluster')
|
||||
def _initdb(self, config: Any) -> bool:
|
||||
self._postgresql.set_state('initializing new cluster')
|
||||
not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version')
|
||||
|
||||
def error_handler(e):
|
||||
def error_handler(e: str) -> None:
|
||||
raise Exception(e)
|
||||
|
||||
options = self.process_user_options('initdb', config or [], not_allowed_options, error_handler)
|
||||
@@ -76,9 +129,8 @@ class Bootstrap(object):
|
||||
os.write(fd, self._postgresql.config.superuser['password'].encode('utf-8'))
|
||||
os.close(fd)
|
||||
options.append('--pwfile={0}'.format(pwfile))
|
||||
options = ['-o', ' '.join(options)] if options else []
|
||||
|
||||
ret = self._postgresql.pg_ctl('initdb', *options)
|
||||
ret = self._postgresql.initdb(*options)
|
||||
if pwfile:
|
||||
os.remove(pwfile)
|
||||
if ret:
|
||||
@@ -87,7 +139,7 @@ class Bootstrap(object):
|
||||
self._postgresql.set_state('initdb failed')
|
||||
return ret
|
||||
|
||||
def _post_restore(self):
|
||||
def _post_restore(self) -> None:
|
||||
self._postgresql.config.restore_configuration_files()
|
||||
self._postgresql.configure_server_parameters()
|
||||
|
||||
@@ -98,10 +150,47 @@ class Bootstrap(object):
|
||||
if os.path.exists(trigger_file):
|
||||
os.unlink(trigger_file)
|
||||
|
||||
def _custom_bootstrap(self, config):
|
||||
def _custom_bootstrap(self, config: Any) -> bool:
|
||||
"""Bootstrap a fresh Patroni cluster using a custom method provided by the user.
|
||||
|
||||
:param config: configuration used for running a custom bootstrap method. It comes from the Patroni YAML file,
|
||||
so it is expected to be a :class:`dict`.
|
||||
|
||||
.. note::
|
||||
*config* must contain a ``command`` key, which value is the command or script to perform the custom
|
||||
bootstrap procedure. The exit code of the ``command`` dictates if the bootstrap succeeded or failed.
|
||||
|
||||
When calling ``command``, Patroni will pass the following arguments to the ``command`` call:
|
||||
|
||||
* ``--scope``: contains the value of ``scope`` configuration;
|
||||
* ``--data_dir``: contains the value of the ``postgresql.data_dir`` configuration.
|
||||
|
||||
You can avoid that behavior by filling the optional key ``no_params`` with the value ``False`` in the
|
||||
configuration file, which will instruct Patroni to not pass these parameters to the ``command`` call.
|
||||
|
||||
Besides that, a couple more keys are supported in *config*, but optional:
|
||||
|
||||
* ``keep_existing_recovery_conf``: if ``True``, instruct Patroni to not remove the existing
|
||||
``recovery.conf`` (PostgreSQL <= 11), to not discard recovery parameters from the configuration
|
||||
(PostgreSQL >= 12), and to not remove the files ``recovery.signal`` or ``standby.signal``
|
||||
(PostgreSQL >= 12). This is specially useful when you are restoring backups through tools like
|
||||
pgBackRest and Barman, in which case they generated the appropriate recovery settings for you;
|
||||
* ``recovery_conf``: a section containing a map, where each key is the name of a recovery related
|
||||
setting, and the value is the value of the corresponding setting.
|
||||
|
||||
Any key/value other than the ones that were described above will be interpreted as additional arguments for
|
||||
the ``command`` call. They will all be added to the call in the format ``--key=value``.
|
||||
|
||||
:returns: ``True`` if the bootstrap was successful, i.e. the execution of the custom ``command`` from *config*
|
||||
exited with code ``0``, ``False`` otherwise.
|
||||
"""
|
||||
self._postgresql.set_state('running custom bootstrap script')
|
||||
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
|
||||
'--datadir=' + self._postgresql.data_dir]
|
||||
# Add custom parameters specified by the user
|
||||
reserved_args = {'command', 'no_params', 'keep_existing_recovery_conf', 'recovery_conf', 'scope', 'datadir'}
|
||||
params += [f"--{arg}={val}" for arg, val in config.items() if arg not in reserved_args]
|
||||
|
||||
try:
|
||||
logger.info('Running custom bootstrap script: %s', config['command'])
|
||||
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
|
||||
@@ -118,13 +207,13 @@ class Bootstrap(object):
|
||||
self._postgresql.config.remove_recovery_conf()
|
||||
return True
|
||||
|
||||
def call_post_bootstrap(self, config):
|
||||
def call_post_bootstrap(self, config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
runs a script after initdb or custom bootstrap script is called and waits until completion.
|
||||
"""
|
||||
cmd = config.get('post_bootstrap') or config.get('post_init')
|
||||
if cmd:
|
||||
r = self._postgresql.config.local_connect_kwargs
|
||||
r = self._postgresql.connection_pool.conn_kwargs
|
||||
connstring = self._postgresql.config.format_dsn(r, True)
|
||||
if 'host' not in r:
|
||||
# https://www.postgresql.org/docs/current/static/libpq-pgpass.html
|
||||
@@ -133,7 +222,7 @@ class Bootstrap(object):
|
||||
r['host'] = 'localhost' # set it to localhost to write into pgpass
|
||||
|
||||
env = self._postgresql.config.write_pgpass(r)
|
||||
env['PGOPTIONS'] = '-c synchronous_commit=local'
|
||||
env['PGOPTIONS'] = '-c synchronous_commit=local -c statement_timeout=0'
|
||||
|
||||
try:
|
||||
ret = self._postgresql.cancellable.call(shlex.split(cmd) + [connstring], env=env)
|
||||
@@ -145,7 +234,7 @@ class Bootstrap(object):
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_replica(self, clone_member):
|
||||
def create_replica(self, clone_member: Union[Leader, Member, None]) -> Optional[int]:
|
||||
"""
|
||||
create the replica according to the replica_method
|
||||
defined by the user. this is a list, so we need to
|
||||
@@ -232,7 +321,7 @@ class Bootstrap(object):
|
||||
self._postgresql.set_state('stopped')
|
||||
return ret
|
||||
|
||||
def basebackup(self, conn_url, env, options):
|
||||
def basebackup(self, conn_url: str, env: Dict[str, str], options: Dict[str, Any]) -> Optional[int]:
|
||||
# creates a replica data dir using pg_basebackup.
|
||||
# this is the default, built-in create_replica_methods
|
||||
# tries twice, then returns failure (as 1)
|
||||
@@ -267,7 +356,7 @@ class Bootstrap(object):
|
||||
|
||||
return ret
|
||||
|
||||
def clone(self, clone_member):
|
||||
def clone(self, clone_member: Union[Leader, Member, None]) -> bool:
|
||||
"""
|
||||
- initialize the replica from an existing member (primary or replica)
|
||||
- initialize the replica using the replica creation method that
|
||||
@@ -280,7 +369,7 @@ class Bootstrap(object):
|
||||
self._post_restore()
|
||||
return ret
|
||||
|
||||
def bootstrap(self, config):
|
||||
def bootstrap(self, config: Dict[str, Any]) -> bool:
|
||||
""" Initialize a new node from scratch and start it. """
|
||||
pg_hba = config.get('pg_hba', [])
|
||||
method = config.get('method') or 'initdb'
|
||||
@@ -292,9 +381,9 @@ class Bootstrap(object):
|
||||
method = 'initdb'
|
||||
do_initialize = self._initdb
|
||||
return do_initialize(config.get(method)) and self._postgresql.config.append_pg_hba(pg_hba) \
|
||||
and self._postgresql.config.save_configuration_files() and self._postgresql.start()
|
||||
and self._postgresql.config.save_configuration_files() and bool(self._postgresql.start())
|
||||
|
||||
def create_or_update_role(self, name, password, options):
|
||||
def create_or_update_role(self, name: str, password: Optional[str], options: List[str]) -> None:
|
||||
options = list(map(str.upper, options))
|
||||
if 'NOLOGIN' not in options and 'LOGIN' not in options:
|
||||
options.append('LOGIN')
|
||||
@@ -324,7 +413,7 @@ END;$$""".format(quote_literal(name), quote_ident(name, self._postgresql.connect
|
||||
self._postgresql.query('RESET log_statement')
|
||||
self._postgresql.query('RESET pg_stat_statements.track_utility')
|
||||
|
||||
def post_bootstrap(self, config, task):
|
||||
def post_bootstrap(self, config: Dict[str, Any], task: CriticalTask) -> Optional[bool]:
|
||||
try:
|
||||
postgresql = self._postgresql
|
||||
superuser = postgresql.config.superuser
|
||||
@@ -348,6 +437,9 @@ BEGIN
|
||||
END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
|
||||
postgresql.query(sql)
|
||||
|
||||
if config.get('users'):
|
||||
logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0')
|
||||
|
||||
for name, value in (config.get('users') or {}).items():
|
||||
if all(name != a.get('username') for a in (superuser, replication, rewind)):
|
||||
self.create_or_update_role(name, value.get('password'), value.get('options', []))
|
||||
|
||||
@@ -1,36 +1,81 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from patroni.postgresql.cancellable import CancellableExecutor
|
||||
from enum import Enum
|
||||
from threading import Condition, Thread
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .cancellable import CancellableExecutor, CancellableSubprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackAction(str, Enum):
|
||||
NOOP = "noop"
|
||||
ON_START = "on_start"
|
||||
ON_STOP = "on_stop"
|
||||
ON_RESTART = "on_restart"
|
||||
ON_RELOAD = "on_reload"
|
||||
ON_ROLE_CHANGE = "on_role_change"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class OnReloadExecutor(CancellableSubprocess):
|
||||
|
||||
def call_nowait(self, cmd: List[str]) -> None:
|
||||
"""Run one `on_reload` callback at most.
|
||||
|
||||
To achieve it we always kill already running command including child processes."""
|
||||
self.cancel(kill=True)
|
||||
self._kill_children()
|
||||
with self._lock:
|
||||
started = self._start_process(cmd, close_fds=True)
|
||||
if started and self._process is not None:
|
||||
Thread(target=self._process.wait).start()
|
||||
|
||||
|
||||
class CallbackExecutor(CancellableExecutor, Thread):
|
||||
|
||||
def __init__(self):
|
||||
CancellableExecutor.__init__(self)
|
||||
Thread.__init__(self)
|
||||
self.daemon = True
|
||||
self._on_reload_executor = OnReloadExecutor()
|
||||
self._cmd = None
|
||||
self._condition = Condition()
|
||||
self.start()
|
||||
|
||||
def call(self, cmd):
|
||||
def call(self, cmd: List[str]) -> None:
|
||||
"""Executes one callback at a time.
|
||||
|
||||
Already running command is killed (including child processes).
|
||||
If it couldn't be killed we wait until it finishes.
|
||||
|
||||
:param cmd: command to be executed"""
|
||||
kwargs: Dict[str, Any] = {'stacklevel': 3} if sys.version_info >= (3, 8) else {}
|
||||
logger.debug('CallbackExecutor.call(%s)', cmd, **kwargs)
|
||||
|
||||
if cmd[-3] == CallbackAction.ON_RELOAD:
|
||||
return self._on_reload_executor.call_nowait(cmd)
|
||||
|
||||
self._kill_process()
|
||||
with self._condition:
|
||||
self._cmd = cmd
|
||||
self._condition.notify()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
with self._condition:
|
||||
if self._cmd is None:
|
||||
self._condition.wait()
|
||||
cmd, self._cmd = self._cmd, None
|
||||
|
||||
with self._lock:
|
||||
if not self._start_process(cmd, close_fds=True):
|
||||
continue
|
||||
self._process.wait()
|
||||
self._kill_children()
|
||||
if cmd is not None:
|
||||
with self._lock:
|
||||
if not self._start_process(cmd, close_fds=True):
|
||||
continue
|
||||
if self._process:
|
||||
self._process.wait()
|
||||
self._kill_children()
|
||||
|
||||
@@ -5,6 +5,7 @@ import subprocess
|
||||
from patroni.exceptions import PostgresException
|
||||
from patroni.utils import polling_loop
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,13 +16,13 @@ class CancellableExecutor(object):
|
||||
There must be only one such process so that AsyncExecutor can easily cancel it.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._process = None
|
||||
self._process_cmd = None
|
||||
self._process_children = []
|
||||
self._process_children: List[psutil.Process] = []
|
||||
self._lock = Lock()
|
||||
|
||||
def _start_process(self, cmd, *args, **kwargs):
|
||||
def _start_process(self, cmd: List[str], *args: Any, **kwargs: Any) -> Optional[bool]:
|
||||
"""This method must be executed only when the `_lock` is acquired"""
|
||||
|
||||
try:
|
||||
@@ -29,10 +30,10 @@ class CancellableExecutor(object):
|
||||
self._process_cmd = cmd
|
||||
self._process = psutil.Popen(cmd, *args, **kwargs)
|
||||
except Exception:
|
||||
return logger.exception('Failed to execute %s', cmd)
|
||||
return logger.exception('Failed to execute %s', cmd)
|
||||
return True
|
||||
|
||||
def _kill_process(self):
|
||||
def _kill_process(self) -> None:
|
||||
with self._lock:
|
||||
if self._process is not None and self._process.is_running() and not self._process_children:
|
||||
try:
|
||||
@@ -53,8 +54,8 @@ class CancellableExecutor(object):
|
||||
except psutil.AccessDenied as e:
|
||||
logger.warning('Failed to kill the process: %s', e.msg)
|
||||
|
||||
def _kill_children(self):
|
||||
waitlist = []
|
||||
def _kill_children(self) -> None:
|
||||
waitlist: List[psutil.Process] = []
|
||||
with self._lock:
|
||||
for child in self._process_children:
|
||||
try:
|
||||
@@ -69,15 +70,16 @@ class CancellableExecutor(object):
|
||||
|
||||
class CancellableSubprocess(CancellableExecutor):
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super(CancellableSubprocess, self).__init__()
|
||||
self._is_cancelled = False
|
||||
|
||||
def call(self, *args, **kwargs):
|
||||
def call(self, *args: Any, **kwargs: Union[Any, Dict[str, str]]) -> Optional[int]:
|
||||
for s in ('stdin', 'stdout', 'stderr'):
|
||||
kwargs.pop(s, None)
|
||||
|
||||
communicate = kwargs.pop('communicate', None)
|
||||
communicate: Optional[Dict[str, str]] = kwargs.pop('communicate', None)
|
||||
input_data = None
|
||||
if isinstance(communicate, dict):
|
||||
input_data = communicate.get('input')
|
||||
if input_data:
|
||||
@@ -96,7 +98,7 @@ class CancellableSubprocess(CancellableExecutor):
|
||||
self._is_cancelled = False
|
||||
started = self._start_process(*args, **kwargs)
|
||||
|
||||
if started:
|
||||
if started and self._process is not None:
|
||||
if isinstance(communicate, dict):
|
||||
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data)
|
||||
return self._process.wait()
|
||||
@@ -105,16 +107,16 @@ class CancellableSubprocess(CancellableExecutor):
|
||||
self._process = None
|
||||
self._kill_children()
|
||||
|
||||
def reset_is_cancelled(self):
|
||||
def reset_is_cancelled(self) -> None:
|
||||
with self._lock:
|
||||
self._is_cancelled = False
|
||||
|
||||
@property
|
||||
def is_cancelled(self):
|
||||
def is_cancelled(self) -> bool:
|
||||
with self._lock:
|
||||
return self._is_cancelled
|
||||
|
||||
def cancel(self, kill=False):
|
||||
def cancel(self, kill: bool = False) -> None:
|
||||
with self._lock:
|
||||
self._is_cancelled = True
|
||||
if self._process is None or not self._process.is_running():
|
||||
|
||||
+128
-97
@@ -2,12 +2,15 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from six.moves.urllib_parse import urlparse
|
||||
from threading import Condition, Event, Thread
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from .connection import Connection
|
||||
from ..dcs import CITUS_COORDINATOR_GROUP_ID
|
||||
from ..psycopg import connect, quote_ident
|
||||
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
|
||||
from ..psycopg import connect, quote_ident, ProgrammingError
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from . import Postgresql
|
||||
|
||||
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,7 +19,8 @@ logger = logging.getLogger(__name__)
|
||||
class PgDistNode(object):
|
||||
"""Represents a single row in the `pg_dist_node` table"""
|
||||
|
||||
def __init__(self, group, host, port, event, nodeid=None, timeout=None, cooldown=None):
|
||||
def __init__(self, group: int, host: str, port: int, event: str, nodeid: Optional[int] = None,
|
||||
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
|
||||
self.group = group
|
||||
# A weird way of pausing client connections by adding the `-demoted` suffix to the hostname
|
||||
self.host = host + ('-demoted' if event == 'before_demote' else '')
|
||||
@@ -29,7 +33,7 @@ class PgDistNode(object):
|
||||
# If transaction was started, we need to COMMIT/ROLLBACK before the deadline
|
||||
self.timeout = timeout
|
||||
self.cooldown = cooldown or 10000 # 10s by default
|
||||
self.deadline = 0
|
||||
self.deadline: float = 0
|
||||
|
||||
# All changes in the pg_dist_node are serialized on the Patroni
|
||||
# side by performing them from a thread. The thread, that is
|
||||
@@ -38,83 +42,81 @@ class PgDistNode(object):
|
||||
# the worker, and once it is done notify the calling thread.
|
||||
self._event = Event()
|
||||
|
||||
def wait(self):
|
||||
def wait(self) -> None:
|
||||
self._event.wait()
|
||||
|
||||
def wakeup(self):
|
||||
def wakeup(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def __eq__(self, other):
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return isinstance(other, PgDistNode) and self.event == other.event\
|
||||
and self.host == other.host and self.port == other.port
|
||||
|
||||
def __ne__(self, other):
|
||||
def __ne__(self, other: Any) -> bool:
|
||||
return not self == other
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})'
|
||||
.format(self.nodeid, self.group, self.host, self.port, self.event))
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
class CitusHandler(Thread):
|
||||
|
||||
def __init__(self, postgresql, config):
|
||||
def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
|
||||
super(CitusHandler, self).__init__()
|
||||
self.daemon = True
|
||||
self._postgresql = postgresql
|
||||
self._config = config
|
||||
self._connection = Connection()
|
||||
self._pg_dist_node = {} # Cache of pg_dist_node: {groupid: PgDistNode()}
|
||||
self._tasks = [] # Requests to change pg_dist_node, every task is a `PgDistNode`
|
||||
self._condition = Condition() # protects _pg_dist_node, _tasks, and _schedule_load_pg_dist_node
|
||||
self._in_flight = None # Reference to the `PgDistNode` if there is a transaction in progress changing it
|
||||
if config:
|
||||
self._connection = postgresql.connection_pool.get(
|
||||
'citus', {'dbname': config['database'],
|
||||
'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
|
||||
self._pg_dist_node: Dict[int, PgDistNode] = {} # Cache of pg_dist_node: {groupid: PgDistNode()}
|
||||
self._tasks: List[PgDistNode] = [] # Requests to change pg_dist_node, every task is a `PgDistNode`
|
||||
self._in_flight: Optional[PgDistNode] = None # Reference to the `PgDistNode` being changed in a transaction
|
||||
self._schedule_load_pg_dist_node = True # Flag that "pg_dist_node" should be queried from the database
|
||||
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
|
||||
self.schedule_cache_rebuild()
|
||||
|
||||
def is_enabled(self):
|
||||
def is_enabled(self) -> bool:
|
||||
return isinstance(self._config, dict)
|
||||
|
||||
def group(self):
|
||||
return self._config['group']
|
||||
def group(self) -> Optional[int]:
|
||||
return int(self._config['group']) if isinstance(self._config, dict) else None
|
||||
|
||||
def is_coordinator(self):
|
||||
def is_coordinator(self) -> bool:
|
||||
return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID
|
||||
|
||||
def is_worker(self):
|
||||
def is_worker(self) -> bool:
|
||||
return self.is_enabled() and not self.is_coordinator()
|
||||
|
||||
def set_conn_kwargs(self, kwargs):
|
||||
if self.is_enabled():
|
||||
kwargs.update({'dbname': self._config['database'],
|
||||
'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
|
||||
self._connection.set_conn_kwargs(kwargs)
|
||||
|
||||
def schedule_cache_rebuild(self):
|
||||
def schedule_cache_rebuild(self) -> None:
|
||||
with self._condition:
|
||||
self._schedule_load_pg_dist_node = True
|
||||
|
||||
def on_demote(self):
|
||||
def on_demote(self) -> None:
|
||||
with self._condition:
|
||||
self._pg_dist_node.clear()
|
||||
self._tasks[:] = []
|
||||
empty_tasks: List[PgDistNode] = []
|
||||
self._tasks[:] = empty_tasks
|
||||
self._in_flight = None
|
||||
|
||||
def query(self, sql, *params):
|
||||
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
|
||||
try:
|
||||
logger.debug('query(%s, %s)', sql, params)
|
||||
cursor = self._connection.cursor()
|
||||
cursor.execute(sql, params or None)
|
||||
return cursor
|
||||
return self._connection.query(sql, *params)
|
||||
except Exception as e:
|
||||
logger.error('Exception when executing query "%s", (%s): %r', sql, params, e)
|
||||
self._connection.close()
|
||||
self._in_flight = None
|
||||
with self._condition:
|
||||
self._in_flight = None
|
||||
self.schedule_cache_rebuild()
|
||||
raise e
|
||||
|
||||
def load_pg_dist_node(self):
|
||||
def load_pg_dist_node(self) -> bool:
|
||||
"""Read from the `pg_dist_node` table and put it into the local cache"""
|
||||
|
||||
with self._condition:
|
||||
@@ -123,16 +125,16 @@ class CitusHandler(Thread):
|
||||
self._schedule_load_pg_dist_node = False
|
||||
|
||||
try:
|
||||
cursor = self.query("SELECT nodeid, groupid, nodename, nodeport, noderole"
|
||||
" FROM pg_catalog.pg_dist_node WHERE noderole = 'primary'")
|
||||
rows = self.query("SELECT nodeid, groupid, nodename, nodeport, noderole"
|
||||
" FROM pg_catalog.pg_dist_node WHERE noderole = 'primary'")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
with self._condition:
|
||||
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in cursor}
|
||||
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows}
|
||||
return True
|
||||
|
||||
def sync_pg_dist_node(self, cluster):
|
||||
def sync_pg_dist_node(self, cluster: Cluster) -> None:
|
||||
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop.
|
||||
|
||||
We can't always rely on REST API calls from worker nodes in order
|
||||
@@ -156,20 +158,22 @@ class CitusHandler(Thread):
|
||||
and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running':
|
||||
self.add_task('after_promote', group, leader.conn_url)
|
||||
|
||||
def find_task_by_group(self, group):
|
||||
def find_task_by_group(self, group: int) -> Optional[int]:
|
||||
for i, task in enumerate(self._tasks):
|
||||
if task.group == group:
|
||||
return i
|
||||
|
||||
def pick_task(self):
|
||||
def pick_task(self) -> Tuple[Optional[int], Optional[PgDistNode]]:
|
||||
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
|
||||
|
||||
Tasks are picked by following priorities:
|
||||
|
||||
1. If there is already a transaction in progress, pick a task
|
||||
that that will change already affected worker primary.
|
||||
2. If the coordinator address should be changed - pick a task
|
||||
with group=0 (coordinators are always in group 0).
|
||||
3. Pick a task that is the oldest (first from the self._tasks)"""
|
||||
3. Pick a task that is the oldest (first from the self._tasks)
|
||||
"""
|
||||
|
||||
with self._condition:
|
||||
if self._in_flight:
|
||||
@@ -195,33 +199,31 @@ class CitusHandler(Thread):
|
||||
task.nodeid = self._pg_dist_node[task.group].nodeid
|
||||
return i, task
|
||||
|
||||
def update_node(self, task):
|
||||
if task.group == CITUS_COORDINATOR_GROUP_ID:
|
||||
return self.query("SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default')",
|
||||
task.host, task.port)
|
||||
|
||||
if task.nodeid is None and task.event != 'before_demote':
|
||||
task.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')",
|
||||
task.host, task.port, task.group).fetchone()[0]
|
||||
elif task.nodeid is not None:
|
||||
# XXX: statement_timeout?
|
||||
def update_node(self, task: PgDistNode) -> None:
|
||||
if task.nodeid is not None:
|
||||
self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)',
|
||||
task.nodeid, task.host, task.port, task.cooldown)
|
||||
elif task.event != 'before_demote':
|
||||
task.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')",
|
||||
task.host, task.port, task.group)[0][0]
|
||||
|
||||
def process_task(self, task):
|
||||
def process_task(self, task: PgDistNode) -> bool:
|
||||
"""Updates a single row in `pg_dist_node` table, optionally in a transaction.
|
||||
|
||||
The transaction is started if we do a demote of the worker node
|
||||
or before promoting the other worker if there is not transaction
|
||||
in progress. And, the transaction it is committed when the
|
||||
switchover/failover completed.
|
||||
The transaction is started if we do a demote of the worker node or before promoting the other worker if
|
||||
there is no transaction in progress. And, the transaction is committed when the switchover/failover completed.
|
||||
|
||||
This method returns `True` if node was updated (optionally,
|
||||
transaction was committed) as an indicator that
|
||||
the `self._pg_dist_node` cache should be updated.
|
||||
.. note:
|
||||
The maximum lifetime of the transaction in progress is controlled outside of this method.
|
||||
|
||||
The maximum lifetime of the transaction in progress
|
||||
is controlled outside of this method."""
|
||||
.. note:
|
||||
Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread.
|
||||
|
||||
:param task: reference to a :class:`PgDistNode` object that represents a row to be updated/created.
|
||||
:returns: `True` if the row was succesfully created/updated or transaction in progress
|
||||
was committed as an indicator that the `self._pg_dist_node` cache should be updated,
|
||||
or, if the new transaction was opened, this method returns `False`.
|
||||
"""
|
||||
|
||||
if task.event == 'after_promote':
|
||||
# The after_promote may happen without previous before_demote and/or
|
||||
@@ -232,7 +234,6 @@ class CitusHandler(Thread):
|
||||
self.update_node(task)
|
||||
if self._in_flight:
|
||||
self.query('COMMIT')
|
||||
self._in_flight = None
|
||||
return True
|
||||
else: # before_demote, before_promote
|
||||
if task.timeout:
|
||||
@@ -240,31 +241,37 @@ class CitusHandler(Thread):
|
||||
if not self._in_flight:
|
||||
self.query('BEGIN')
|
||||
self.update_node(task)
|
||||
self._in_flight = task
|
||||
return False
|
||||
|
||||
def process_tasks(self):
|
||||
def process_tasks(self) -> None:
|
||||
while True:
|
||||
# Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread.
|
||||
if not self._in_flight and not self.load_pg_dist_node():
|
||||
break
|
||||
|
||||
i, task = self.pick_task()
|
||||
if not task:
|
||||
if not task or i is None:
|
||||
break
|
||||
try:
|
||||
update_cache = self.process_task(task)
|
||||
except Exception as e:
|
||||
logger.error('Exception when working with pg_dist_node: %r', e)
|
||||
update_cache = False
|
||||
update_cache = None
|
||||
with self._condition:
|
||||
if self._tasks:
|
||||
if update_cache:
|
||||
self._pg_dist_node[task.group] = task
|
||||
|
||||
if update_cache is False: # an indicator that process_tasks has started a transaction
|
||||
self._in_flight = task
|
||||
else:
|
||||
self._in_flight = None
|
||||
|
||||
if id(self._tasks[i]) == id(task):
|
||||
self._tasks.pop(i)
|
||||
task.wakeup()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
with self._condition:
|
||||
@@ -285,15 +292,23 @@ class CitusHandler(Thread):
|
||||
except Exception:
|
||||
logger.exception('run')
|
||||
|
||||
def _add_task(self, task):
|
||||
def _add_task(self, task: PgDistNode) -> bool:
|
||||
with self._condition:
|
||||
i = self.find_task_by_group(task.group)
|
||||
|
||||
# task.timeout is None is an indicator that it was scheduled
|
||||
# from the sync_pg_dist_node() and we don't want to override
|
||||
# already existing task created from REST API.
|
||||
if task.timeout is None and (i is not None or self._in_flight and self._in_flight.group == task.group):
|
||||
return False
|
||||
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
|
||||
if task.timeout is None:
|
||||
# We don't want to override the already existing task created from REST API.
|
||||
if i is not None and self._tasks[i].timeout is not None:
|
||||
return False
|
||||
|
||||
# There is a little race condition with tasks created from REST API - the call made "before" the member
|
||||
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
|
||||
# task based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
|
||||
# Only when the timeout is reached new tasks could be scheduled from sync_pg_dist_node()
|
||||
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
|
||||
and self._in_flight.deadline > time.time():
|
||||
return False
|
||||
|
||||
# Override already existing task for the same worker group
|
||||
if i is not None:
|
||||
@@ -311,41 +326,49 @@ class CitusHandler(Thread):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_task(self, event, group, conn_url, timeout=None, cooldown=None):
|
||||
def add_task(self, event: str, group: int, conn_url: str,
|
||||
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistNode]:
|
||||
try:
|
||||
r = urlparse(conn_url)
|
||||
except Exception as e:
|
||||
return logger.error('Failed to parse connection url %s: %r', conn_url, e)
|
||||
host = r.hostname
|
||||
port = r.port or 5432
|
||||
task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown)
|
||||
return task if self._add_task(task) else None
|
||||
if host:
|
||||
port = r.port or 5432
|
||||
task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown)
|
||||
return task if self._add_task(task) else None
|
||||
|
||||
def handle_event(self, cluster, event):
|
||||
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
|
||||
if not self.is_alive():
|
||||
return
|
||||
|
||||
cluster = cluster.workers.get(event['group'])
|
||||
if not (cluster and cluster.leader and cluster.leader.name == event['leader'] and cluster.leader.conn_url):
|
||||
worker = cluster.workers.get(event['group'])
|
||||
if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url):
|
||||
return
|
||||
|
||||
task = self.add_task(event['type'], event['group'],
|
||||
cluster.leader.conn_url,
|
||||
event['timeout'], event['cooldown']*1000)
|
||||
worker.leader.conn_url,
|
||||
event['timeout'], event['cooldown'] * 1000)
|
||||
if task and event['type'] == 'before_demote':
|
||||
task.wait()
|
||||
|
||||
def bootstrap(self):
|
||||
if not self.is_enabled():
|
||||
def bootstrap(self) -> None:
|
||||
if not isinstance(self._config, dict): # self.is_enabled()
|
||||
return
|
||||
|
||||
conn_kwargs = self._postgresql.config.local_connect_kwargs
|
||||
conn_kwargs['options'] = '-c synchronous_commit=local -c statement_timeout=0'
|
||||
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
|
||||
'options': '-c synchronous_commit=local -c statement_timeout=0'}
|
||||
if self._config['database'] != self._postgresql.database:
|
||||
conn = connect(**conn_kwargs)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE DATABASE {0}'.format(quote_ident(self._config['database'], conn)))
|
||||
cur.execute('CREATE DATABASE {0}'.format(
|
||||
quote_ident(self._config['database'], conn)).encode('utf-8'))
|
||||
except ProgrammingError as exc:
|
||||
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
|
||||
logger.debug('Exception when creating database: %r', exc)
|
||||
else:
|
||||
raise exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -353,7 +376,7 @@ class CitusHandler(Thread):
|
||||
conn = connect(**conn_kwargs)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE EXTENSION citus')
|
||||
cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
|
||||
|
||||
superuser = self._postgresql.config.superuser
|
||||
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
|
||||
@@ -361,10 +384,15 @@ class CitusHandler(Thread):
|
||||
cur.execute("INSERT INTO pg_catalog.pg_dist_authinfo VALUES"
|
||||
"(0, pg_catalog.current_user(), %s)",
|
||||
(self._postgresql.config.format_dsn(params),))
|
||||
|
||||
if self.is_coordinator():
|
||||
r = urlparse(self._postgresql.connection_string)
|
||||
cur.execute("SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default')",
|
||||
(r.hostname, r.port or 5432))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def adjust_postgres_gucs(self, parameters):
|
||||
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
@@ -375,15 +403,18 @@ class CitusHandler(Thread):
|
||||
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
|
||||
|
||||
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
|
||||
if parameters.get('max_prepared_transactions') == 0:
|
||||
if parameters['max_prepared_transactions'] == 0:
|
||||
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
|
||||
|
||||
# Resharding in Citus implemented using logical replication
|
||||
parameters['wal_level'] = 'logical'
|
||||
|
||||
def ignore_replication_slot(self, slot):
|
||||
if self.is_enabled() and self._postgresql.is_leader() and\
|
||||
# Sometimes Citus needs to connect to the local postgres. We will do it the same way as Patroni does.
|
||||
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
|
||||
|
||||
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
||||
if isinstance(self._config, dict) and self._postgresql.is_primary() and\
|
||||
slot['type'] == 'logical' and slot['database'] == self._config['database']:
|
||||
m = CITUS_SLOT_NAME_RE.match(slot['name'])
|
||||
return m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin']
|
||||
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
|
||||
return False
|
||||
|
||||
+337
-190
File diff suppressed because it is too large
Load Diff
@@ -2,44 +2,158 @@ import logging
|
||||
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Connection, Cursor
|
||||
from psycopg2 import connection, cursor
|
||||
|
||||
from .. import psycopg
|
||||
from ..exceptions import PostgresConnectionException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Connection(object):
|
||||
class NamedConnection:
|
||||
"""Helper class to manage ``psycopg`` connections from Patroni to PostgreSQL.
|
||||
|
||||
def __init__(self):
|
||||
self._lock = Lock()
|
||||
:ivar server_version: PostgreSQL version in integer format where we are connected to.
|
||||
"""
|
||||
|
||||
server_version: int
|
||||
|
||||
def __init__(self, pool: 'ConnectionPool', name: str, kwargs_override: Optional[Dict[str, Any]]) -> None:
|
||||
"""Create an instance of :class:`NamedConnection` class.
|
||||
|
||||
:param pool: reference to a :class:`ConnectionPool` object.
|
||||
:param name: name of the connection.
|
||||
:param kwargs_override: :class:`dict` object with connection parameters that should be
|
||||
different from default values provided by connection *pool*.
|
||||
"""
|
||||
self._pool = pool
|
||||
self._name = name
|
||||
self._kwargs_override = kwargs_override or {}
|
||||
self._lock = Lock() # used to make sure that only one connection to postgres is established
|
||||
self._connection = None
|
||||
self._cursor_holder = None
|
||||
|
||||
def set_conn_kwargs(self, conn_kwargs):
|
||||
self._conn_kwargs = conn_kwargs
|
||||
@property
|
||||
def _conn_kwargs(self) -> Dict[str, Any]:
|
||||
"""Connection parameters for this :class:`NamedConnection`."""
|
||||
return {**self._pool.conn_kwargs, **self._kwargs_override, 'application_name': f'Patroni {self._name}'}
|
||||
|
||||
def get(self):
|
||||
def get(self) -> Union['connection', 'Connection[Any]']:
|
||||
"""Get ``psycopg``/``psycopg2`` connection object.
|
||||
|
||||
.. note::
|
||||
Opens a new connection if necessary.
|
||||
|
||||
:returns: ``psycopg`` or ``psycopg2`` connection object.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._connection or self._connection.closed != 0:
|
||||
logger.info("establishing a new patroni %s connection to postgres", self._name)
|
||||
self._connection = psycopg.connect(**self._conn_kwargs)
|
||||
self.server_version = self._connection.server_version
|
||||
self.server_version = getattr(self._connection, 'server_version', 0)
|
||||
return self._connection
|
||||
|
||||
def cursor(self):
|
||||
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
|
||||
logger.info("establishing a new patroni connection to the postgres cluster")
|
||||
self._cursor_holder = self.get().cursor()
|
||||
return self._cursor_holder
|
||||
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
|
||||
"""Execute a query with parameters and optionally returns a response.
|
||||
|
||||
def close(self):
|
||||
:param sql: SQL statement to execute.
|
||||
:param params: parameters to pass.
|
||||
|
||||
:returns: a query response as a list of tuples if there is any.
|
||||
:raises:
|
||||
:exc:`~psycopg.Error` if had issues while executing *sql*.
|
||||
|
||||
:exc:`~patroni.exceptions.PostgresConnectionException`: if had issues while connecting to the database.
|
||||
"""
|
||||
cursor = None
|
||||
try:
|
||||
with self.get().cursor() as cursor:
|
||||
cursor.execute(sql.encode('utf-8'), params or None)
|
||||
return cursor.fetchall() if cursor.rowcount and cursor.rowcount > 0 else []
|
||||
except psycopg.Error as exc:
|
||||
if cursor and cursor.connection.closed == 0:
|
||||
# When connected via unix socket, psycopg2 can't recoginze 'connection lost' and leaves
|
||||
# `self._connection.closed == 0`, but the generic exception is raised. It doesn't make
|
||||
# sense to continue with existing connection and we will close it, to avoid its reuse.
|
||||
if type(exc) in (psycopg.DatabaseError, psycopg.OperationalError):
|
||||
self.close()
|
||||
else:
|
||||
raise exc
|
||||
raise PostgresConnectionException('connection problems') from exc
|
||||
|
||||
def close(self, silent: bool = False) -> bool:
|
||||
"""Close the psycopg connection to postgres.
|
||||
|
||||
:param silent: whether the method should not write logs.
|
||||
|
||||
:returns: ``True`` if ``psycopg`` connection was closed, ``False`` otherwise.``
|
||||
"""
|
||||
ret = False
|
||||
if self._connection and self._connection.closed == 0:
|
||||
self._connection.close()
|
||||
logger.info("closed patroni connection to the postgresql cluster")
|
||||
self._cursor_holder = self._connection = None
|
||||
if not silent:
|
||||
logger.info("closed patroni %s connection to postgres", self._name)
|
||||
ret = True
|
||||
self._connection = None
|
||||
return ret
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""Helper class to manage named connections from Patroni to PostgreSQL.
|
||||
|
||||
The instance keeps named :class:`NamedConnection` objects and parameters that must be used for new connections.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create an instance of :class:`ConnectionPool` class."""
|
||||
self._lock = Lock()
|
||||
self._connections: Dict[str, NamedConnection] = {}
|
||||
self._conn_kwargs: Dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def conn_kwargs(self) -> Dict[str, Any]:
|
||||
"""Connection parameters that must be used for new ``psycopg`` connections."""
|
||||
with self._lock:
|
||||
return self._conn_kwargs.copy()
|
||||
|
||||
@conn_kwargs.setter
|
||||
def conn_kwargs(self, value: Dict[str, Any]) -> None:
|
||||
"""Set new connection parameters.
|
||||
|
||||
:param value: :class:`dict` object with connection parameters.
|
||||
"""
|
||||
with self._lock:
|
||||
self._conn_kwargs = value
|
||||
|
||||
def get(self, name: str, kwargs_override: Optional[Dict[str, Any]] = None) -> NamedConnection:
|
||||
"""Get a new named :class:`NamedConnection` object from the pool.
|
||||
|
||||
.. note::
|
||||
Creates a new :class:`NamedConnection` object if it doesn't yet exist in the pool.
|
||||
|
||||
:param name: name of the connection.
|
||||
:param kwargs_override: :class:`dict` object with connection parameters that should be
|
||||
different from default values provided by :attr:`conn_kwargs`.
|
||||
|
||||
:returns: :class:`NamedConnection` object.
|
||||
"""
|
||||
with self._lock:
|
||||
if name not in self._connections:
|
||||
self._connections[name] = NamedConnection(self, name, kwargs_override)
|
||||
return self._connections[name]
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close all named connections from Patroni to PostgreSQL registered in the pool."""
|
||||
with self._lock:
|
||||
closed_connections = [conn.close(True) for conn in self._connections.values()]
|
||||
if any(closed_connections):
|
||||
logger.info("closed patroni connections to postgres")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection_cursor(**kwargs):
|
||||
def get_connection_cursor(**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
conn = psycopg.connect(**kwargs)
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
|
||||
+10
-10
@@ -2,12 +2,14 @@ import errno
|
||||
import logging
|
||||
import os
|
||||
|
||||
from patroni.exceptions import PostgresException
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from ..exceptions import PostgresException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def postgres_version_to_int(pg_version):
|
||||
def postgres_version_to_int(pg_version: str) -> int:
|
||||
"""Convert the server_version to integer
|
||||
|
||||
>>> postgres_version_to_int('9.5.3')
|
||||
@@ -45,7 +47,7 @@ def postgres_version_to_int(pg_version):
|
||||
return int(''.join('{0:02d}'.format(c) for c in components))
|
||||
|
||||
|
||||
def postgres_major_version_to_int(pg_version):
|
||||
def postgres_major_version_to_int(pg_version: str) -> int:
|
||||
"""
|
||||
>>> postgres_major_version_to_int('10')
|
||||
100000
|
||||
@@ -55,29 +57,27 @@ def postgres_major_version_to_int(pg_version):
|
||||
return postgres_version_to_int(pg_version + '.0')
|
||||
|
||||
|
||||
def parse_lsn(lsn):
|
||||
def parse_lsn(lsn: str) -> int:
|
||||
t = lsn.split('/')
|
||||
return int(t[0], 16) * 0x100000000 + int(t[1], 16)
|
||||
|
||||
|
||||
def parse_history(data):
|
||||
def parse_history(data: str) -> Iterable[Tuple[int, int, str]]:
|
||||
for line in data.split('\n'):
|
||||
values = line.strip().split('\t')
|
||||
if len(values) == 3:
|
||||
try:
|
||||
values[0] = int(values[0])
|
||||
values[1] = parse_lsn(values[1])
|
||||
yield values
|
||||
yield int(values[0]), parse_lsn(values[1]), values[2]
|
||||
except (IndexError, ValueError):
|
||||
logger.exception('Exception when parsing timeline history line "%s"', values)
|
||||
|
||||
|
||||
def format_lsn(lsn, full=False):
|
||||
def format_lsn(lsn: int, full: bool = False) -> str:
|
||||
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
|
||||
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def fsync_dir(path):
|
||||
def fsync_dir(path: str) -> None:
|
||||
if os.name != 'nt':
|
||||
fd = os.open(path, os.O_DIRECTORY)
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,9 @@ import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from multiprocessing.connection import Connection
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from patroni import PATRONI_ENV_PREFIX, KUBERNETES_ENV_PREFIX
|
||||
|
||||
# avoid spawning the resource tracker process
|
||||
@@ -26,7 +29,7 @@ STOP_SIGNALS = {
|
||||
}
|
||||
|
||||
|
||||
def pg_ctl_start(conn, cmdline, env):
|
||||
def pg_ctl_start(conn: Connection, cmdline: List[str], env: Dict[str, str]) -> None:
|
||||
if os.name != 'nt':
|
||||
os.setsid()
|
||||
try:
|
||||
@@ -40,7 +43,8 @@ def pg_ctl_start(conn, cmdline, env):
|
||||
|
||||
class PostmasterProcess(psutil.Process):
|
||||
|
||||
def __init__(self, pid):
|
||||
def __init__(self, pid: int) -> None:
|
||||
self._postmaster_pid: Dict[str, str]
|
||||
self.is_single_user = False
|
||||
if pid < 0:
|
||||
pid = -pid
|
||||
@@ -48,7 +52,7 @@ class PostmasterProcess(psutil.Process):
|
||||
super(PostmasterProcess, self).__init__(pid)
|
||||
|
||||
@staticmethod
|
||||
def _read_postmaster_pidfile(data_dir):
|
||||
def _read_postmaster_pidfile(data_dir: str) -> Dict[str, str]:
|
||||
"""Reads and parses postmaster.pid from the data directory
|
||||
|
||||
:returns dictionary of values if successful, empty dictionary otherwise
|
||||
@@ -60,7 +64,7 @@ class PostmasterProcess(psutil.Process):
|
||||
except IOError:
|
||||
return {}
|
||||
|
||||
def _is_postmaster_process(self):
|
||||
def _is_postmaster_process(self) -> bool:
|
||||
try:
|
||||
start_time = int(self._postmaster_pid.get('start_time', 0))
|
||||
if start_time and abs(self.create_time() - start_time) > 3:
|
||||
@@ -79,7 +83,7 @@ class PostmasterProcess(psutil.Process):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _from_pidfile(cls, data_dir):
|
||||
def _from_pidfile(cls, data_dir: str) -> Optional['PostmasterProcess']:
|
||||
postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir)
|
||||
try:
|
||||
pid = int(postmaster_pid.get('pid', 0))
|
||||
@@ -88,10 +92,10 @@ class PostmasterProcess(psutil.Process):
|
||||
proc._postmaster_pid = postmaster_pid
|
||||
return proc
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def from_pidfile(data_dir):
|
||||
def from_pidfile(data_dir: str) -> Optional['PostmasterProcess']:
|
||||
try:
|
||||
proc = PostmasterProcess._from_pidfile(data_dir)
|
||||
return proc if proc and proc._is_postmaster_process() else None
|
||||
@@ -99,13 +103,13 @@ class PostmasterProcess(psutil.Process):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_pid(cls, pid):
|
||||
def from_pid(cls, pid: int) -> Optional['PostmasterProcess']:
|
||||
try:
|
||||
return cls(pid)
|
||||
except psutil.NoSuchProcess:
|
||||
return None
|
||||
|
||||
def signal_kill(self):
|
||||
def signal_kill(self) -> bool:
|
||||
"""to suspend and kill postmaster and all children
|
||||
|
||||
:returns True if postmaster and children are killed, False if error
|
||||
@@ -141,7 +145,7 @@ class PostmasterProcess(psutil.Process):
|
||||
psutil.wait_procs(children + [self])
|
||||
return True
|
||||
|
||||
def signal_stop(self, mode, pg_ctl='pg_ctl'):
|
||||
def signal_stop(self, mode: str, pg_ctl: str = 'pg_ctl') -> Optional[bool]:
|
||||
"""Signal postmaster process to stop
|
||||
|
||||
:returns None if signaled, True if process is already gone, False if error
|
||||
@@ -161,7 +165,7 @@ class PostmasterProcess(psutil.Process):
|
||||
|
||||
return None
|
||||
|
||||
def pg_ctl_kill(self, mode, pg_ctl):
|
||||
def pg_ctl_kill(self, mode: str, pg_ctl: str) -> Optional[bool]:
|
||||
try:
|
||||
status = subprocess.call([pg_ctl, "kill", STOP_SIGNALS[mode], str(self.pid)])
|
||||
except OSError:
|
||||
@@ -171,7 +175,7 @@ class PostmasterProcess(psutil.Process):
|
||||
else:
|
||||
return not self.is_running()
|
||||
|
||||
def wait_for_user_backends_to_close(self, stop_timeout):
|
||||
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
|
||||
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
|
||||
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
|
||||
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
|
||||
@@ -183,8 +187,8 @@ class PostmasterProcess(psutil.Process):
|
||||
except psutil.Error:
|
||||
return logger.debug('Failed to get list of postmaster children')
|
||||
|
||||
user_backends = []
|
||||
user_backends_cmdlines = {}
|
||||
user_backends: List[psutil.Process] = []
|
||||
user_backends_cmdlines: Dict[int, str] = {}
|
||||
for child in children:
|
||||
try:
|
||||
cmdline = child.cmdline()
|
||||
@@ -195,7 +199,7 @@ class PostmasterProcess(psutil.Process):
|
||||
pass
|
||||
if user_backends:
|
||||
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines.values()))
|
||||
gone, live = psutil.wait_procs(user_backends, stop_timeout)
|
||||
_, live = psutil.wait_procs(user_backends, stop_timeout)
|
||||
if stop_timeout and live:
|
||||
live = [user_backends_cmdlines[b.pid] for b in live]
|
||||
logger.warning('Backends still alive after %s: %s', stop_timeout, ', '.join(live))
|
||||
@@ -203,7 +207,7 @@ class PostmasterProcess(psutil.Process):
|
||||
logger.debug("Backends closed")
|
||||
|
||||
@staticmethod
|
||||
def start(pgcommand, data_dir, conf, options):
|
||||
def start(pgcommand: str, data_dir: str, conf: str, options: List[str]) -> Optional['PostmasterProcess']:
|
||||
# Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information
|
||||
# it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start
|
||||
# in python. It will start postgres, wait for port to be open and wait until postgres will start
|
||||
@@ -234,7 +238,7 @@ class PostmasterProcess(psutil.Process):
|
||||
pass
|
||||
cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options
|
||||
logger.debug("Starting postgres: %s", " ".join(cmdline))
|
||||
ctx = multiprocessing.get_context('spawn') if sys.version_info >= (3, 4) else multiprocessing
|
||||
ctx = multiprocessing.get_context('spawn')
|
||||
parent_conn, child_conn = ctx.Pipe(False)
|
||||
proc = ctx.Process(target=pg_ctl_start, args=(child_conn, cmdline, env))
|
||||
proc.start()
|
||||
|
||||
+127
-89
@@ -3,39 +3,48 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import six
|
||||
import subprocess
|
||||
|
||||
from enum import IntEnum
|
||||
from threading import Lock, Thread
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple
|
||||
|
||||
from . import Postgresql
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import format_lsn, fsync_dir, parse_history, parse_lsn
|
||||
from ..async_executor import CriticalTask
|
||||
from ..dcs import Leader
|
||||
from ..dcs import Leader, RemoteMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECKPOINT': 1, 'CHECK': 2, 'NEED': 3,
|
||||
'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6})
|
||||
|
||||
class REWIND_STATUS(IntEnum):
|
||||
INITIAL = 0
|
||||
CHECKPOINT = 1
|
||||
CHECK = 2
|
||||
NEED = 3
|
||||
NOT_NEED = 4
|
||||
SUCCESS = 5
|
||||
FAILED = 6
|
||||
|
||||
|
||||
class Rewind(object):
|
||||
|
||||
def __init__(self, postgresql):
|
||||
def __init__(self, postgresql: Postgresql) -> None:
|
||||
self._postgresql = postgresql
|
||||
self._checkpoint_task_lock = Lock()
|
||||
self.reset_state()
|
||||
|
||||
@staticmethod
|
||||
def configuration_allows_rewind(data):
|
||||
def configuration_allows_rewind(data: Dict[str, str]) -> bool:
|
||||
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return self._postgresql.config.get('use_pg_rewind')
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._postgresql.config.get('use_pg_rewind'))
|
||||
|
||||
@property
|
||||
def can_rewind(self):
|
||||
def can_rewind(self) -> bool:
|
||||
""" check if pg_rewind executable is there and that pg_controldata indicates
|
||||
we have either wal_log_hints or checksums turned on
|
||||
"""
|
||||
@@ -53,85 +62,106 @@ class Rewind(object):
|
||||
return self.configuration_allows_rewind(self._postgresql.controldata())
|
||||
|
||||
@property
|
||||
def should_remove_data_directory_on_diverged_timelines(self):
|
||||
return self._postgresql.config.get('remove_data_directory_on_diverged_timelines')
|
||||
def should_remove_data_directory_on_diverged_timelines(self) -> bool:
|
||||
return bool(self._postgresql.config.get('remove_data_directory_on_diverged_timelines'))
|
||||
|
||||
@property
|
||||
def can_rewind_or_reinitialize_allowed(self):
|
||||
def can_rewind_or_reinitialize_allowed(self) -> bool:
|
||||
return self.should_remove_data_directory_on_diverged_timelines or self.can_rewind
|
||||
|
||||
def trigger_check_diverged_lsn(self):
|
||||
def trigger_check_diverged_lsn(self) -> None:
|
||||
if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED:
|
||||
self._state = REWIND_STATUS.CHECK
|
||||
|
||||
@staticmethod
|
||||
def check_leader_is_not_in_recovery(conn_kwargs):
|
||||
def check_leader_is_not_in_recovery(conn_kwargs: Dict[str, Any]) -> Optional[bool]:
|
||||
try:
|
||||
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
|
||||
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||
if not cur.fetchone()[0]:
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return True
|
||||
logger.info('Leader is still in_recovery and therefore can\'t be used for rewind')
|
||||
except Exception:
|
||||
return logger.exception('Exception when working with leader')
|
||||
|
||||
@staticmethod
|
||||
def check_leader_has_run_checkpoint(conn_kwargs):
|
||||
def check_leader_has_run_checkpoint(conn_kwargs: Dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
|
||||
cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()" +
|
||||
" AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name(" +
|
||||
" pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id" +
|
||||
cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()"
|
||||
" AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name("
|
||||
" pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id"
|
||||
" FROM pg_catalog.pg_control_checkpoint()")
|
||||
if not cur.fetchone()[0]:
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return 'leader has not run a checkpoint yet'
|
||||
except Exception:
|
||||
logger.exception('Exception when working with leader')
|
||||
return 'not accessible or not healty'
|
||||
|
||||
def _get_checkpoint_end(self, timeline, lsn):
|
||||
"""The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
|
||||
Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump
|
||||
and parse the output. We are trying to read two records, and expect that it will fail to read the second one:
|
||||
`pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0`
|
||||
The error message contains information about LSN of the next record, which is exactly where checkpoint ends."""
|
||||
def _get_checkpoint_end(self, timeline: int, lsn: int) -> int:
|
||||
"""Get the end of checkpoint record from WAL.
|
||||
|
||||
.. note::
|
||||
The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
|
||||
Hence, the only reliable way to figure out where it ends, is to read the record from file with the
|
||||
help of ``pg_waldump`` and parse the output.
|
||||
|
||||
We are trying to read two records, and expect that it will fail to read the second record with message:
|
||||
|
||||
fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0; or
|
||||
|
||||
fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: expected at least 24, got 0
|
||||
|
||||
The error message contains information about LSN of the next record, which is exactly where checkpoint ends.
|
||||
|
||||
:param timeline: the checkpoint *timeline* from ``pg_controldata``.
|
||||
:param lsn: the checkpoint *location* as :class:`int` from ``pg_controldata``.
|
||||
|
||||
:returns: the end of checkpoint record as :class:`int` or ``0`` if failed to parse ``pg_waldump`` output.
|
||||
"""
|
||||
lsn8 = format_lsn(lsn, True)
|
||||
lsn = format_lsn(lsn)
|
||||
out, err = self._postgresql.waldump(timeline, lsn, 2)
|
||||
lsn_str = format_lsn(lsn)
|
||||
out, err = self._postgresql.waldump(timeline, lsn_str, 2)
|
||||
if out is not None and err is not None:
|
||||
out = out.decode('utf-8').rstrip().split('\n')
|
||||
err = err.decode('utf-8').rstrip().split('\n')
|
||||
pattern = 'error in WAL record at {0}: invalid record length at '.format(lsn)
|
||||
pattern = 'error in WAL record at {0}: invalid record length at '.format(lsn_str)
|
||||
|
||||
if len(out) == 1 and len(err) == 1 and ', lsn: {0}, prev '.format(lsn8) in out[0] and pattern in err[0]:
|
||||
i = err[0].find(pattern) + len(pattern)
|
||||
j = err[0].find(": wanted ", i)
|
||||
if j > -1:
|
||||
try:
|
||||
return parse_lsn(err[0][i:j])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
|
||||
# Message format depends on the major version:
|
||||
# * expected at least -- starting from v16
|
||||
# * wanted -- before v16
|
||||
# We will simply check all possible combinations.
|
||||
for pattern in (': expected at least ', ': wanted '):
|
||||
j = err[0].find(pattern, i)
|
||||
if j > -1:
|
||||
try:
|
||||
return parse_lsn(err[0][i:j])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
|
||||
logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name)
|
||||
logger.error(' stdout=%s', '\n'.join(out))
|
||||
logger.error(' stderr=%s', '\n'.join(err))
|
||||
|
||||
return 0
|
||||
|
||||
def _get_local_timeline_lsn_from_controldata(self):
|
||||
def _get_local_timeline_lsn_from_controldata(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
|
||||
in_recovery = timeline = lsn = None
|
||||
data = self._postgresql.controldata()
|
||||
try:
|
||||
if data.get('Database cluster state') in ('shut down in recovery', 'in archive recovery'):
|
||||
in_recovery = True
|
||||
lsn = data.get('Minimum recovery ending location')
|
||||
timeline = int(data.get("Min recovery ending loc's timeline"))
|
||||
timeline = int(data.get("Min recovery ending loc's timeline", ""))
|
||||
if lsn == '0/0' or timeline == 0: # it was a primary when it crashed
|
||||
data['Database cluster state'] = 'shut down'
|
||||
if data.get('Database cluster state') == 'shut down':
|
||||
in_recovery = False
|
||||
lsn = data.get('Latest checkpoint location')
|
||||
timeline = int(data.get("Latest checkpoint's TimeLineID"))
|
||||
timeline = int(data.get("Latest checkpoint's TimeLineID", ""))
|
||||
except (TypeError, ValueError):
|
||||
logger.exception('Failed to get local timeline and lsn from pg_controldata output')
|
||||
|
||||
@@ -144,27 +174,28 @@ class Rewind(object):
|
||||
|
||||
return in_recovery, timeline, lsn
|
||||
|
||||
def _get_local_timeline_lsn(self):
|
||||
def _get_local_timeline_lsn(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
|
||||
if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection
|
||||
in_recovery = True
|
||||
timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline()
|
||||
timeline = self._postgresql.get_replica_timeline()
|
||||
lsn = self._postgresql.replayed_location()
|
||||
else: # otherwise analyze pg_controldata output
|
||||
in_recovery, timeline, lsn = self._get_local_timeline_lsn_from_controldata()
|
||||
|
||||
log_lsn = format_lsn(lsn) if isinstance(lsn, six.integer_types) else lsn
|
||||
log_lsn = format_lsn(lsn) if isinstance(lsn, int) else lsn
|
||||
logger.info('Local timeline=%s lsn=%s', timeline, log_lsn)
|
||||
return in_recovery, timeline, lsn
|
||||
|
||||
@staticmethod
|
||||
def _log_primary_history(history, i):
|
||||
def _log_primary_history(history: List[Tuple[int, int, str]], i: int) -> None:
|
||||
start = max(0, i - 3)
|
||||
end = None if i + 4 >= len(history) else i + 2
|
||||
history_show = []
|
||||
history_show: List[str] = []
|
||||
|
||||
def format_history_line(line):
|
||||
def format_history_line(line: Tuple[int, int, str]) -> str:
|
||||
return '{0}\t{1}\t{2}'.format(line[0], format_lsn(line[1]), line[2])
|
||||
|
||||
line = None
|
||||
for line in history[start:end]:
|
||||
history_show.append(format_history_line(line))
|
||||
|
||||
@@ -174,7 +205,7 @@ class Rewind(object):
|
||||
|
||||
logger.info('primary: history=%s', '\n'.join(history_show))
|
||||
|
||||
def _conn_kwargs(self, member, auth):
|
||||
def _conn_kwargs(self, member: Union[Leader, RemoteMember], auth: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ret = member.conn_kwargs(auth)
|
||||
if not ret.get('dbname'):
|
||||
ret['dbname'] = self._postgresql.database
|
||||
@@ -184,7 +215,7 @@ class Rewind(object):
|
||||
ret['target_session_attrs'] = 'read-write'
|
||||
return ret
|
||||
|
||||
def _check_timeline_and_lsn(self, leader):
|
||||
def _check_timeline_and_lsn(self, leader: Union[Leader, RemoteMember]) -> None:
|
||||
in_recovery, local_timeline, local_lsn = self._get_local_timeline_lsn()
|
||||
if local_timeline is None or local_lsn is None:
|
||||
return
|
||||
@@ -206,23 +237,28 @@ class Rewind(object):
|
||||
try:
|
||||
with self._postgresql.get_replication_connection_cursor(**leader.conn_kwargs()) as cur:
|
||||
cur.execute('IDENTIFY_SYSTEM')
|
||||
primary_timeline = cur.fetchone()[1]
|
||||
logger.info('primary_timeline=%s', primary_timeline)
|
||||
if local_timeline > primary_timeline: # Not always supported by pg_rewind
|
||||
need_rewind = True
|
||||
elif local_timeline == primary_timeline:
|
||||
need_rewind = False
|
||||
elif primary_timeline > 1:
|
||||
cur.execute('TIMELINE_HISTORY {0}'.format(primary_timeline))
|
||||
history = cur.fetchone()[1]
|
||||
if not isinstance(history, six.string_types):
|
||||
history = bytes(history).decode('utf-8')
|
||||
logger.debug('primary: history=%s', history)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
primary_timeline = row[1]
|
||||
logger.info('primary_timeline=%s', primary_timeline)
|
||||
if local_timeline > primary_timeline: # Not always supported by pg_rewind
|
||||
need_rewind = True
|
||||
elif local_timeline == primary_timeline:
|
||||
need_rewind = False
|
||||
elif primary_timeline > 1:
|
||||
cur.execute('TIMELINE_HISTORY {0}'.format(primary_timeline).encode('utf-8'))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
history = row[1]
|
||||
if not isinstance(history, str):
|
||||
history = bytes(history).decode('utf-8')
|
||||
logger.debug('primary: history=%s', history)
|
||||
except Exception:
|
||||
return logger.exception('Exception when working with primary via replication connection')
|
||||
|
||||
if history is not None:
|
||||
history = list(parse_history(history))
|
||||
i = len(history)
|
||||
for i, (parent_timeline, switchpoint, _) in enumerate(history):
|
||||
if parent_timeline == local_timeline:
|
||||
# We don't need to rewind when:
|
||||
@@ -244,12 +280,12 @@ class Rewind(object):
|
||||
|
||||
self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
|
||||
|
||||
def rewind_or_reinitialize_needed_and_possible(self, leader):
|
||||
def rewind_or_reinitialize_needed_and_possible(self, leader: Union[Leader, RemoteMember, None]) -> bool:
|
||||
if leader and leader.name != self._postgresql.name and leader.conn_url and self._state == REWIND_STATUS.CHECK:
|
||||
self._check_timeline_and_lsn(leader)
|
||||
return leader and leader.conn_url and self._state == REWIND_STATUS.NEED
|
||||
return bool(leader and leader.conn_url) and self._state == REWIND_STATUS.NEED
|
||||
|
||||
def __checkpoint(self, task, wakeup):
|
||||
def __checkpoint(self, task: CriticalTask, wakeup: Callable[..., Any]) -> None:
|
||||
try:
|
||||
result = self._postgresql.checkpoint()
|
||||
except Exception as e:
|
||||
@@ -259,11 +295,11 @@ class Rewind(object):
|
||||
if task.result:
|
||||
wakeup()
|
||||
|
||||
def ensure_checkpoint_after_promote(self, wakeup):
|
||||
def ensure_checkpoint_after_promote(self, wakeup: Callable[..., Any]) -> None:
|
||||
"""After promote issue a CHECKPOINT from a new thread and asynchronously check the result.
|
||||
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_primary():
|
||||
with self._checkpoint_task_lock:
|
||||
if self._checkpoint_task:
|
||||
with self._checkpoint_task:
|
||||
@@ -276,10 +312,10 @@ class Rewind(object):
|
||||
self._checkpoint_task = CriticalTask()
|
||||
Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
|
||||
|
||||
def checkpoint_after_promote(self):
|
||||
def checkpoint_after_promote(self) -> bool:
|
||||
return self._state == REWIND_STATUS.CHECKPOINT
|
||||
|
||||
def _buid_archiver_command(self, command, wal_filename):
|
||||
def _buid_archiver_command(self, command: str, wal_filename: str) -> str:
|
||||
"""Replace placeholders in the given archiver command's template.
|
||||
Applicable for archive_command and restore_command.
|
||||
Can also be used for archive_cleanup_command and recovery_end_command,
|
||||
@@ -307,13 +343,13 @@ class Rewind(object):
|
||||
|
||||
return cmd
|
||||
|
||||
def _fetch_missing_wal(self, restore_command, wal_filename):
|
||||
def _fetch_missing_wal(self, restore_command: str, wal_filename: str) -> bool:
|
||||
cmd = self._buid_archiver_command(restore_command, wal_filename)
|
||||
|
||||
logger.info('Trying to fetch the missing wal: %s', cmd)
|
||||
return self._postgresql.cancellable.call(shlex.split(cmd)) == 0
|
||||
|
||||
def _find_missing_wal(self, data):
|
||||
def _find_missing_wal(self, data: bytes) -> Optional[str]:
|
||||
# could not open file "$PGDATA/pg_wal/0000000A00006AA100000068": No such file or directory
|
||||
pattern = 'could not open file "'
|
||||
for line in data.decode('utf-8').split('\n'):
|
||||
@@ -326,7 +362,7 @@ class Rewind(object):
|
||||
if waldir.endswith('/pg_' + self._postgresql.wal_name) and len(wal_filename) == 24:
|
||||
return wal_filename
|
||||
|
||||
def _archive_ready_wals(self):
|
||||
def _archive_ready_wals(self) -> None:
|
||||
"""Try to archive WALs that have .ready files just in case
|
||||
archive_mode was not set to 'always' before promote, while
|
||||
after it the WALs were recycled on the promoted replica.
|
||||
@@ -353,7 +389,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)
|
||||
@@ -362,7 +398,7 @@ class Rewind(object):
|
||||
else:
|
||||
logger.info('Failed to archive WAL segment %s', wal)
|
||||
|
||||
def _maybe_clean_pg_replslot(self):
|
||||
def _maybe_clean_pg_replslot(self) -> None:
|
||||
"""Clean pg_replslot directory if pg version is less then 11
|
||||
(pg_rewind deletes $PGDATA/pg_replslot content only since pg11)."""
|
||||
if self._postgresql.major_version < 110000:
|
||||
@@ -374,32 +410,33 @@ class Rewind(object):
|
||||
except Exception as e:
|
||||
logger.warning('Unable to clean %s: %r', replslot_dir, e)
|
||||
|
||||
def pg_rewind(self, r):
|
||||
def pg_rewind(self, r: Dict[str, Any]) -> bool:
|
||||
# prepare pg_rewind connection
|
||||
env = self._postgresql.config.write_pgpass(r)
|
||||
env.update(LANG='C', LC_ALL='C', PGOPTIONS='-c statement_timeout=0')
|
||||
dsn = self._postgresql.config.format_dsn(r, True)
|
||||
logger.info('running pg_rewind from %s', dsn)
|
||||
|
||||
restore_command = self._postgresql.config.get('recovery_conf', {}).get('restore_command') \
|
||||
restore_command = (self._postgresql.config.get('recovery_conf') or {}).get('restore_command') \
|
||||
if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command')
|
||||
|
||||
# Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian
|
||||
pg_rewind_can_restore = restore_command and (self._postgresql.major_version >= 150000 or
|
||||
(self._postgresql.major_version >= 130000 and
|
||||
self._postgresql.config._config_dir == self._postgresql.data_dir))
|
||||
pg_rewind_can_restore = restore_command and (self._postgresql.major_version >= 150000
|
||||
or (self._postgresql.major_version >= 130000
|
||||
and self._postgresql.config.config_dir
|
||||
== self._postgresql.data_dir))
|
||||
|
||||
cmd = [self._postgresql.pgcommand('pg_rewind')]
|
||||
if pg_rewind_can_restore:
|
||||
cmd.append('--restore-target-wal')
|
||||
if self._postgresql.major_version >= 150000 and\
|
||||
self._postgresql.config._config_dir != self._postgresql.data_dir:
|
||||
self._postgresql.config.config_dir != self._postgresql.data_dir:
|
||||
cmd.append('--config-file={0}'.format(self._postgresql.config.postgresql_conf))
|
||||
|
||||
cmd.extend(['-D', self._postgresql.data_dir, '--source-server', dsn])
|
||||
|
||||
while True:
|
||||
results = {}
|
||||
results: Dict[str, bytes] = {}
|
||||
ret = self._postgresql.cancellable.call(cmd, env=env, communicate=results)
|
||||
|
||||
logger.info('pg_rewind exit code=%s', ret)
|
||||
@@ -422,7 +459,7 @@ class Rewind(object):
|
||||
logger.info('Failed to fetch WAL segment %s required for pg_rewind', missing_wal)
|
||||
return False
|
||||
|
||||
def execute(self, leader):
|
||||
def execute(self, leader: Union[Leader, RemoteMember]) -> Optional[bool]:
|
||||
if self._postgresql.is_running() and not self._postgresql.stop(checkpoint=False):
|
||||
return logger.warning('Can not run pg_rewind because postgres is still running')
|
||||
|
||||
@@ -440,7 +477,7 @@ class Rewind(object):
|
||||
# superuser credentials match rewind_credentials if the latter are not provided or we run 10 or older
|
||||
if self._postgresql.config.superuser == self._postgresql.config.rewind_credentials:
|
||||
leader_status = self._postgresql.checkpoint(
|
||||
self._conn_kwargs(leader, self._postgresql.config.superuser))
|
||||
self._conn_kwargs(leader, self._postgresql.config.superuser))
|
||||
else: # we run 11+ and have a dedicated pg_rewind user
|
||||
leader_status = self.check_leader_has_run_checkpoint(r)
|
||||
if leader_status: # we tried to run/check for a checkpoint on the remote leader, but it failed
|
||||
@@ -471,26 +508,26 @@ class Rewind(object):
|
||||
break
|
||||
return False
|
||||
|
||||
def reset_state(self):
|
||||
def reset_state(self) -> None:
|
||||
self._state = REWIND_STATUS.INITIAL
|
||||
with self._checkpoint_task_lock:
|
||||
self._checkpoint_task = None
|
||||
|
||||
@property
|
||||
def is_needed(self):
|
||||
def is_needed(self) -> bool:
|
||||
return self._state in (REWIND_STATUS.CHECK, REWIND_STATUS.NEED)
|
||||
|
||||
@property
|
||||
def executed(self):
|
||||
def executed(self) -> bool:
|
||||
return self._state > REWIND_STATUS.NOT_NEED
|
||||
|
||||
@property
|
||||
def failed(self):
|
||||
def failed(self) -> bool:
|
||||
return self._state == REWIND_STATUS.FAILED
|
||||
|
||||
def read_postmaster_opts(self):
|
||||
def read_postmaster_opts(self) -> Dict[str, str]:
|
||||
"""returns the list of option names/values from postgres.opts, Empty dict if read failed or no file"""
|
||||
result = {}
|
||||
result: Dict[str, str] = {}
|
||||
try:
|
||||
with open(os.path.join(self._postgresql.data_dir, 'postmaster.opts')) as f:
|
||||
data = f.read()
|
||||
@@ -502,7 +539,8 @@ class Rewind(object):
|
||||
logger.exception('Error when reading postmaster.opts')
|
||||
return result
|
||||
|
||||
def single_user_mode(self, communicate=None, options=None):
|
||||
def single_user_mode(self, communicate: Optional[Dict[str, Any]] = None,
|
||||
options: Optional[Dict[str, str]] = None) -> Optional[int]:
|
||||
"""run a given command in a single-user mode. If the command is empty - then just start and stop"""
|
||||
cmd = [self._postgresql.pgcommand('postgres'), '--single', '-D', self._postgresql.data_dir]
|
||||
for opt, val in sorted((options or {}).items()):
|
||||
@@ -511,7 +549,7 @@ class Rewind(object):
|
||||
cmd.append('template1')
|
||||
return self._postgresql.cancellable.call(cmd, communicate=communicate)
|
||||
|
||||
def cleanup_archive_status(self):
|
||||
def cleanup_archive_status(self) -> None:
|
||||
status_dir = os.path.join(self._postgresql.wal_dir, 'archive_status')
|
||||
try:
|
||||
for f in os.listdir(status_dir):
|
||||
@@ -526,7 +564,7 @@ class Rewind(object):
|
||||
except OSError:
|
||||
logger.exception('Unable to list %s', status_dir)
|
||||
|
||||
def ensure_clean_shutdown(self):
|
||||
def ensure_clean_shutdown(self) -> Optional[bool]:
|
||||
self._archive_ready_wals()
|
||||
self.cleanup_archive_status()
|
||||
|
||||
@@ -534,7 +572,7 @@ class Rewind(object):
|
||||
opts = self.read_postmaster_opts()
|
||||
opts.update({'archive_mode': 'on', 'archive_command': 'false'})
|
||||
self._postgresql.config.remove_recovery_conf()
|
||||
output = {}
|
||||
output: Dict[str, bytes] = {}
|
||||
ret = self.single_user_mode(communicate=output, options=opts)
|
||||
if ret != 0:
|
||||
logger.error('Crash recovery finished with code=%s', ret)
|
||||
|
||||
+462
-152
@@ -1,40 +1,83 @@
|
||||
"""Replication slot handling.
|
||||
|
||||
Provides classes for the creation, monitoring, management and synchronisation of PostgreSQL replication slots.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from threading import Condition, Thread
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECKING, Collection
|
||||
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import format_lsn, fsync_dir
|
||||
from ..dcs import Cluster, Leader
|
||||
from ..file_perm import pg_perm
|
||||
from ..psycopg import OperationalError
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Cursor
|
||||
from psycopg2 import cursor
|
||||
from . import Postgresql
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compare_slots(s1, s2, dbid='database'):
|
||||
return s1['type'] == s2['type'] and (s1['type'] == 'physical' or
|
||||
s1.get(dbid) == s2.get(dbid) and s1['plugin'] == s2['plugin'])
|
||||
def compare_slots(s1: Dict[str, Any], s2: Dict[str, Any], dbid: str = 'database') -> bool:
|
||||
"""Compare 2 replication slot objects for equality.
|
||||
|
||||
..note ::
|
||||
If the first argument is a ``physical`` replication slot then only the `type` of the second slot is compared.
|
||||
If the first argument is another ``type`` (e.g. ``logical``) then *dbid* and ``plugin`` are compared.
|
||||
|
||||
:param s1: First slot dictionary to be compared.
|
||||
:param s2: Second slot dictionary to be compared.
|
||||
:param dbid: Optional attribute to be compared when comparing ``logical`` replication slots.
|
||||
|
||||
:return: ``True`` if the slot ``type`` of *s1* and *s2* is matches, and the ``type`` of *s1* is ``physical``,
|
||||
OR the ``types`` match AND the *dbid* and ``plugin`` attributes are equal.
|
||||
|
||||
"""
|
||||
return (s1['type'] == s2['type']
|
||||
and (s1['type'] == 'physical'
|
||||
or s1.get(dbid) == s2.get(dbid)
|
||||
and s1['plugin'] == s2['plugin']))
|
||||
|
||||
|
||||
class SlotsAdvanceThread(Thread):
|
||||
"""Daemon process :class:``Thread`` object for advancing logical replication slots on replicas.
|
||||
|
||||
def __init__(self, slots_handler):
|
||||
super(SlotsAdvanceThread, self).__init__()
|
||||
This ensures that slot advancing queries sent to postgres do not block the main loop.
|
||||
"""
|
||||
|
||||
def __init__(self, slots_handler: 'SlotsHandler') -> None:
|
||||
"""Create and start a new thread for handling slot advance queries.
|
||||
|
||||
:param slots_handler: The calling class instance for reference to slot information attributes.
|
||||
"""
|
||||
super().__init__()
|
||||
self.daemon = True
|
||||
self._slots_handler = slots_handler
|
||||
|
||||
# _copy_slots and _failed are used to asynchronously give some feedback to the main thread
|
||||
self._copy_slots = []
|
||||
self._copy_slots: List[str] = []
|
||||
self._failed = False
|
||||
|
||||
self._scheduled = defaultdict(dict) # {'dbname1': {'slot1': 100, 'slot2': 100}, 'dbname2': {'slot3': 100}}
|
||||
# {'dbname1': {'slot1': 100, 'slot2': 100}, 'dbname2': {'slot3': 100}}
|
||||
self._scheduled: Dict[str, Dict[str, int]] = defaultdict(dict)
|
||||
self._condition = Condition() # protect self._scheduled from concurrent access and to wakeup the run() method
|
||||
|
||||
self.start()
|
||||
|
||||
def sync_slot(self, cur, database, slot, lsn):
|
||||
def sync_slot(self, cur: Union['cursor', 'Cursor[Any]'], database: str, slot: str, lsn: int) -> None:
|
||||
"""Execute a ``pg_replication_slot_advance`` query and store success for scheduled synchronisation task.
|
||||
|
||||
:param cur: database connection cursor.
|
||||
:param database: name of the database associated with the slot.
|
||||
:param slot: name of the slot to be synchronised.
|
||||
:param lsn: last known LSN position
|
||||
"""
|
||||
failed = copy = False
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", (slot, format_lsn(lsn)))
|
||||
@@ -55,7 +98,12 @@ class SlotsAdvanceThread(Thread):
|
||||
if not self._scheduled[database]:
|
||||
self._scheduled.pop(database)
|
||||
|
||||
def sync_slots_in_database(self, database, slots):
|
||||
def sync_slots_in_database(self, database: str, slots: List[str]) -> None:
|
||||
"""Synchronise slots for a single database.
|
||||
|
||||
:param database: name of the database.
|
||||
:param slots: list of slot names to synchronise.
|
||||
"""
|
||||
with self._slots_handler.get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur:
|
||||
for slot in slots:
|
||||
with self._condition:
|
||||
@@ -63,7 +111,8 @@ class SlotsAdvanceThread(Thread):
|
||||
if lsn:
|
||||
self.sync_slot(cur, database, slot, lsn)
|
||||
|
||||
def sync_slots(self):
|
||||
def sync_slots(self) -> None:
|
||||
"""Synchronise slots for all scheduled databases."""
|
||||
with self._condition:
|
||||
databases = list(self._scheduled.keys())
|
||||
for database in databases:
|
||||
@@ -75,7 +124,13 @@ class SlotsAdvanceThread(Thread):
|
||||
except Exception as e:
|
||||
logger.error('Failed to advance replication slots in database %s: %r', database, e)
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
"""Thread main loop entrypoint.
|
||||
|
||||
.. note::
|
||||
Thread will wait until a sync is scheduled from outside, normally triggered during the HA loop or a wakeup
|
||||
call.
|
||||
"""
|
||||
while True:
|
||||
with self._condition:
|
||||
if not self._scheduled:
|
||||
@@ -83,7 +138,15 @@ class SlotsAdvanceThread(Thread):
|
||||
|
||||
self.sync_slots()
|
||||
|
||||
def schedule(self, advance_slots):
|
||||
def schedule(self, advance_slots: Dict[str, Dict[str, int]]) -> Tuple[bool, List[str]]:
|
||||
"""Trigger a synchronisation of slots.
|
||||
|
||||
This is the main entrypoint for Patroni HA loop wakeup call.
|
||||
|
||||
:param advance_slots: dictionary containing slots that need to be advanced
|
||||
|
||||
:return: tuple of failure status and a list of slots to be copied
|
||||
"""
|
||||
with self._condition:
|
||||
for database, values in advance_slots.items():
|
||||
self._scheduled[database].update(values)
|
||||
@@ -94,102 +157,182 @@ class SlotsAdvanceThread(Thread):
|
||||
|
||||
return ret
|
||||
|
||||
def on_promote(self):
|
||||
def on_promote(self) -> None:
|
||||
"""Reset state of the daemon."""
|
||||
with self._condition:
|
||||
self._scheduled.clear()
|
||||
self._failed = False
|
||||
self._copy_slots = []
|
||||
|
||||
|
||||
class SlotsHandler(object):
|
||||
class SlotsHandler:
|
||||
"""Handler for managing and storing information on replication slots in PostgreSQL.
|
||||
|
||||
def __init__(self, postgresql):
|
||||
:ivar pg_replslot_dir: system location path of the PostgreSQL replication slots.
|
||||
:ivar _logical_slots_processing_queue: yet to be processed logical replication slots on the primary
|
||||
"""
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql') -> None:
|
||||
"""Create an instance with storage attributes for replication slots and schedule the first synchronisation.
|
||||
|
||||
:param postgresql: Calling class instance providing interface to PostgreSQL.
|
||||
"""
|
||||
self._force_readiness_check = False
|
||||
self._schedule_load_slots = False
|
||||
self._postgresql = postgresql
|
||||
self._advance = None
|
||||
self._replication_slots = {} # already existing replication slots
|
||||
self._unready_logical_slots = {}
|
||||
self._replication_slots: Dict[str, Dict[str, Any]] = {} # already existing replication slots
|
||||
self._logical_slots_processing_queue: Dict[str, Optional[int]] = {}
|
||||
self.pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
|
||||
self.schedule()
|
||||
|
||||
def _query(self, sql, *params):
|
||||
def _query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
|
||||
"""Helper method for :meth:`Postgresql.query`.
|
||||
|
||||
:param sql: SQL statement to execute.
|
||||
:param params: parameters to pass through to :meth:`Postgresql.query`.
|
||||
|
||||
:returns: query response.
|
||||
"""
|
||||
return self._postgresql.query(sql, *params, retry=False)
|
||||
|
||||
@staticmethod
|
||||
def _copy_items(src, dst, keys=None):
|
||||
def _copy_items(src: Dict[str, Any], dst: Dict[str, Any], keys: Optional[Collection[str]] = None) -> None:
|
||||
"""Select values from *src* dictionary to update in *dst* dictionary for optional supplied *keys*.
|
||||
|
||||
:param src: source dictionary that *keys* will be looked up from.
|
||||
:param dst: destination dictionary to be updated.
|
||||
:param keys: optional list of keys to be looked up in the source dictionary.
|
||||
"""
|
||||
dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')})
|
||||
|
||||
def process_permanent_slots(self, slots):
|
||||
"""This methods solves three problems at once (I know, it is weird).
|
||||
def process_permanent_slots(self, slots: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
"""Process replication slot information from the host and prepare information used in subsequent cluster tasks.
|
||||
|
||||
.. note::
|
||||
This methods solves three problems.
|
||||
|
||||
The ``cluster_info_query`` from :class:``Postgresql`` is executed every HA loop and returns information
|
||||
about all replication slots that exists on the current host.
|
||||
|
||||
Based on this information perform the following actions:
|
||||
|
||||
1. For the primary we want to expose to DCS permanent logical slots, therefore build (and return) a dict
|
||||
that maps permanent logical slot names to ``confirmed_flush_lsn``.
|
||||
2. detect if one of the previously known permanent slots is missing and schedule resync.
|
||||
3. Update the local cache with the fresh ``catalog_xmin`` and ``confirmed_flush_lsn`` for every known slot.
|
||||
|
||||
The cluster_info_query from `Postgresql` is executed every HA loop and returns
|
||||
information about all replication slots that exists on the current host.
|
||||
Based on this information we perform the following actions:
|
||||
1. For the primary we want to expose to DCS permanent logical slots, therefore the method
|
||||
builds (and returns) a dict, that maps permanent logical slot names and confirmed_flush_lsns.
|
||||
2. This method also detects if one of the previously known permanent slots got missing and schedules resync.
|
||||
3. Updates the local cache with the fresh catalog_xmin and confirmed_flush_lsn for every known slot.
|
||||
This info is used when performing the check of logical slot readiness on standbys.
|
||||
|
||||
:param slots: replication slot information that exists on the current host.
|
||||
|
||||
:return: dictionary of logical slot names to ``confirmed_flush_lsn``.
|
||||
"""
|
||||
ret = {}
|
||||
ret: Dict[str, int] = {}
|
||||
|
||||
slots = {slot['slot_name']: slot for slot in slots or []}
|
||||
if slots:
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots:
|
||||
if compare_slots(value, self._replication_slots[name], 'datoid'):
|
||||
if value['type'] == 'logical':
|
||||
ret[name] = value['confirmed_flush_lsn']
|
||||
self._copy_items(value, self._replication_slots[name])
|
||||
slots_dict: Dict[str, Dict[str, Any]] = {slot['slot_name']: slot for slot in slots or []}
|
||||
for name, value in slots_dict.items():
|
||||
if name in self._replication_slots:
|
||||
if compare_slots(value, self._replication_slots[name], 'datoid'):
|
||||
if value['type'] == 'logical':
|
||||
ret[name] = value['confirmed_flush_lsn']
|
||||
self._copy_items(value, self._replication_slots[name])
|
||||
else:
|
||||
self._schedule_load_slots = True
|
||||
self._replication_slots[name]['restart_lsn'] = ret[name] = value['restart_lsn']
|
||||
else:
|
||||
self._schedule_load_slots = True
|
||||
|
||||
# It could happen that the slots was deleted in the background, we want to detect this case
|
||||
if any(name not in slots for name in self._replication_slots.keys()):
|
||||
# It could happen that the slot was deleted in the background, we want to detect this case
|
||||
if any(name not in slots_dict for name in self._replication_slots.keys()):
|
||||
self._schedule_load_slots = True
|
||||
|
||||
return ret
|
||||
|
||||
def load_replication_slots(self):
|
||||
def load_replication_slots(self) -> None:
|
||||
"""Query replication slot information from the database and store it for processing by other tasks.
|
||||
|
||||
.. note::
|
||||
Only supported from PostgreSQL version 9.4 onwards.
|
||||
|
||||
Store replication slot ``name``, ``type``, ``plugin``, ``database`` and ``datoid``.
|
||||
If PostgreSQL version is 10 or newer also store ``catalog_xmin`` and ``confirmed_flush_lsn``.
|
||||
|
||||
When using logical slots, store information separately for slot synchronisation on replica nodes.
|
||||
"""
|
||||
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
|
||||
replication_slots = {}
|
||||
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
|
||||
replication_slots: Dict[str, Dict[str, Any]] = {}
|
||||
pg_wal_lsn_diff = f"pg_catalog.pg_{self._postgresql.wal_name}_{self._postgresql.lsn_name}_diff"
|
||||
extra = f", catalog_xmin, {pg_wal_lsn_diff}(confirmed_flush_lsn, '0/0')::bigint" \
|
||||
if self._postgresql.major_version >= 100000 else ""
|
||||
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
|
||||
cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid'
|
||||
'{0} FROM pg_catalog.pg_replication_slots{1}'.format(extra, skip_temp_slots))
|
||||
for r in cursor:
|
||||
for r in self._query(f"SELECT slot_name, slot_type, {pg_wal_lsn_diff}(restart_lsn, '0/0')::bigint, plugin,"
|
||||
f" database, datoid{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}"):
|
||||
value = {'type': r[1]}
|
||||
if r[1] == 'logical':
|
||||
value.update(plugin=r[2], database=r[3], datoid=r[4])
|
||||
value.update(plugin=r[3], database=r[4], datoid=r[5])
|
||||
if self._postgresql.major_version >= 100000:
|
||||
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
|
||||
value.update(catalog_xmin=r[6], confirmed_flush_lsn=r[7])
|
||||
else:
|
||||
value['restart_lsn'] = r[2]
|
||||
replication_slots[r[0]] = value
|
||||
self._replication_slots = replication_slots
|
||||
self._schedule_load_slots = False
|
||||
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, name):
|
||||
def ignore_replication_slot(self, cluster: Cluster, name: str) -> bool:
|
||||
"""Check if slot *name* should not be managed by Patroni.
|
||||
|
||||
:param cluster: cluster state information object.
|
||||
:param name: name of the slot to ignore
|
||||
|
||||
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
|
||||
otherwise will pass through and return result of :meth:`CitusHandler.ignore_replication_slot`.
|
||||
"""
|
||||
slot = self._replication_slots[name]
|
||||
for matcher in cluster.config.ignore_slots_matchers:
|
||||
if ((matcher.get("name") is None or matcher["name"] == name)
|
||||
and all(not matcher.get(a) or matcher[a] == slot.get(a) for a in ('database', 'plugin', 'type'))):
|
||||
return True
|
||||
if cluster.config:
|
||||
for matcher in cluster.config.ignore_slots_matchers:
|
||||
if (
|
||||
(matcher.get("name") is None or matcher["name"] == name)
|
||||
and all(not matcher.get(a) or matcher[a] == slot.get(a)
|
||||
for a in ('database', 'plugin', 'type'))
|
||||
):
|
||||
return True
|
||||
return self._postgresql.citus_handler.ignore_replication_slot(slot)
|
||||
|
||||
def drop_replication_slot(self, name):
|
||||
"""Returns a tuple(active, dropped)"""
|
||||
cursor = self._query(('WITH slots AS (SELECT slot_name, active' +
|
||||
' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),' +
|
||||
' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),' +
|
||||
' true AS dropped FROM slots WHERE not active) ' +
|
||||
'SELECT active, COALESCE(dropped, false) FROM slots' +
|
||||
' FULL OUTER JOIN dropped ON true'), name)
|
||||
return cursor.fetchone() if cursor.rowcount == 1 else (False, False)
|
||||
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
|
||||
"""Drop a named slot from Postgres.
|
||||
|
||||
def _drop_incorrect_slots(self, cluster, slots, paused):
|
||||
# drop old replication slots which are not presented in desired slots
|
||||
:param name: name of the slot to be dropped.
|
||||
|
||||
:returns: a tuple of ``active`` and ``dropped``. ``active`` is ``True`` if the slot is active,
|
||||
``dropped`` is ``True`` if the slot was successfully dropped. If the slot was not found return
|
||||
``False`` for both.
|
||||
"""
|
||||
rows = self._query(('WITH slots AS (SELECT slot_name, active'
|
||||
' FROM pg_catalog.pg_replication_slots WHERE slot_name = %s),'
|
||||
' dropped AS (SELECT pg_catalog.pg_drop_replication_slot(slot_name),'
|
||||
' true AS dropped FROM slots WHERE not active) '
|
||||
'SELECT active, COALESCE(dropped, false) FROM slots'
|
||||
' FULL OUTER JOIN dropped ON true'), name)
|
||||
return (rows[0][0], rows[0][1]) if rows else (False, False)
|
||||
|
||||
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
|
||||
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
|
||||
|
||||
.. note::
|
||||
Slots that are not contained in *slots* will be dropped.
|
||||
Slots can be filtered out with ``ignore_slots`` configuration.
|
||||
|
||||
Slots that have matching names but do not match attributes in *slots* will also be dropped.
|
||||
|
||||
:param cluster: cluster state information object.
|
||||
:param slots: dictionary of desired slot names as keys with slot attributes as a dictionary value, if known.
|
||||
:param paused: ``True`` if the patroni cluster is currently in a paused state.
|
||||
"""
|
||||
# drop old replication slots which are not presented in desired slots.
|
||||
for name in set(self._replication_slots) - set(slots):
|
||||
if not paused and not self.ignore_replication_slot(cluster, name):
|
||||
active, dropped = self.drop_replication_slot(name)
|
||||
@@ -201,6 +344,8 @@ class SlotsHandler(object):
|
||||
logger.debug("Unable to drop unknown replication slot '%s', slot is still active", name)
|
||||
else:
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
|
||||
# drop slots with matching names but attributes that do not match, e.g. `plugin` or `database`.
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
|
||||
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
|
||||
@@ -211,32 +356,68 @@ class SlotsHandler(object):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def _ensure_physical_slots(self, slots):
|
||||
def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None:
|
||||
"""Create or advance physical replication *slots*.
|
||||
|
||||
Any failures are logged and do not interrupt creation of all *slots*.
|
||||
|
||||
:param slots: A dictionary mapping slot name to slot attributes. This method only considers a slot
|
||||
if the value is a dictionary with the key ``type`` and a value of ``physical``.
|
||||
"""
|
||||
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
|
||||
for name, value in slots.items():
|
||||
if name not in self._replication_slots and value['type'] == 'physical':
|
||||
if value['type'] != 'physical':
|
||||
continue
|
||||
if name not in self._replication_slots:
|
||||
try:
|
||||
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
|
||||
immediately_reserve), name, name)
|
||||
self._query(f"SELECT pg_catalog.pg_create_physical_replication_slot(%s{immediately_reserve})"
|
||||
f" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
|
||||
f" WHERE slot_type = 'physical' AND slot_name = %s)",
|
||||
name, name)
|
||||
except Exception:
|
||||
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
elif self._postgresql.can_advance_slots and self._replication_slots[name]['type'] == 'physical':
|
||||
value['restart_lsn'] = self._replication_slots[name]['restart_lsn']
|
||||
lsn = value.get('lsn')
|
||||
if lsn and lsn > value['restart_lsn']: # The slot has feedback in DCS and needs to be advanced
|
||||
try:
|
||||
lsn = format_lsn(lsn)
|
||||
self._query("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", name, lsn)
|
||||
except Exception as exc:
|
||||
logger.error("Error while advancing replication slot %s to position '%s': %r", name, lsn, exc)
|
||||
|
||||
@contextmanager
|
||||
def get_local_connection_cursor(self, **kwargs):
|
||||
conn_kwargs = self._postgresql.config.local_connect_kwargs
|
||||
conn_kwargs.update(kwargs)
|
||||
def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
"""Create a new database connection to local server.
|
||||
|
||||
Create a non-blocking connection cursor to avoid the situation where an execution of the query of
|
||||
``pg_replication_slot_advance`` takes longer than the timeout on a HA loop, which could cause a false
|
||||
failure state.
|
||||
|
||||
:param kwargs: Any keyword arguments to pass to :func:`psycopg.connect`.
|
||||
|
||||
:yields: connection cursor object, note implementation varies depending on version of :mod:`psycopg`.
|
||||
"""
|
||||
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs, **kwargs}
|
||||
with get_connection_cursor(**conn_kwargs) as cur:
|
||||
yield cur
|
||||
|
||||
def _ensure_logical_slots_primary(self, slots):
|
||||
def _ensure_logical_slots_primary(self, slots: Dict[str, Any]) -> None:
|
||||
"""Create any missing logical replication *slots* on the primary.
|
||||
|
||||
If the logical slot already exists, copy state information into the replication slots structure stored in the
|
||||
class instance.
|
||||
|
||||
:param slots: Slots that should exist are supplied in a dictionary, mapping slot name to any attributes.
|
||||
The method will only consider slots that have a value that is a dictionary with a key ``type``
|
||||
with a value that is ``logical``.
|
||||
|
||||
"""
|
||||
# Group logical slots to be created by database name
|
||||
logical_slots = defaultdict(dict)
|
||||
logical_slots: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(dict)
|
||||
for name, value in slots.items():
|
||||
if value['type'] == 'logical':
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if self._replication_slots.get(name, {}).get('datoid'):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
else:
|
||||
@@ -247,8 +428,8 @@ class SlotsHandler(object):
|
||||
with self.get_local_connection_cursor(dbname=database) as cur:
|
||||
for name, value in values.items():
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)"
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
|
||||
" WHERE slot_type = 'logical' AND slot_name = %s)",
|
||||
(name, value['plugin'], name))
|
||||
except Exception as e:
|
||||
@@ -257,53 +438,96 @@ class SlotsHandler(object):
|
||||
slots.pop(name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def schedule_advance_slots(self, slots):
|
||||
def schedule_advance_slots(self, slots: Dict[str, Dict[str, int]]) -> Tuple[bool, List[str]]:
|
||||
"""Wrapper to ensure slots advance daemon thread is started if not already.
|
||||
|
||||
:param slots: dictionary containing slot information.
|
||||
|
||||
:return: tuple with the result of the scheduling of slot advancement: ``failed`` and list of slots to copy.
|
||||
"""
|
||||
if not self._advance:
|
||||
self._advance = SlotsAdvanceThread(self)
|
||||
return self._advance.schedule(slots)
|
||||
|
||||
def _ensure_logical_slots_replica(self, cluster, slots):
|
||||
advance_slots = defaultdict(dict) # Group logical slots to be advanced by database name
|
||||
create_slots = [] # And collect logical slots to be created on the replica
|
||||
def _ensure_logical_slots_replica(self, slots: Dict[str, Any]) -> List[str]:
|
||||
"""Update logical *slots* on replicas.
|
||||
|
||||
If the logical slot already exists, copy state information into the replication slots structure stored in the
|
||||
class instance. Slots that exist are also advanced if their ``confirmed_flush_lsn`` is greater than the stored
|
||||
state of the slot.
|
||||
|
||||
As logical slots can only be created when the primary is available, pass the list of slots that need to be
|
||||
copied back to the caller. They will be created on replicas with :meth:`SlotsHandler.copy_logical_slots`.
|
||||
|
||||
:param slots: A dictionary mapping slot name to slot attributes. This method only considers a slot
|
||||
if the value is a dictionary with the key ``type`` and a value of ``logical``.
|
||||
|
||||
:returns: list of slots to be copied from the primary.
|
||||
"""
|
||||
# Group logical slots to be advanced by database name
|
||||
advance_slots: Dict[str, Dict[str, int]] = defaultdict(dict)
|
||||
create_slots: List[str] = [] # Collect logical slots to be created on the replica
|
||||
|
||||
for name, value in slots.items():
|
||||
if value['type'] == 'logical':
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if self._replication_slots.get(name, {}).get('datoid'):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
if name in cluster.slots:
|
||||
try: # Skip slots that doesn't need to be advanced
|
||||
if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
|
||||
advance_slots[value['database']][name] = int(cluster.slots[name])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse "%s": %r', cluster.slots[name], e)
|
||||
elif name in cluster.slots: # We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
if value['type'] != 'logical':
|
||||
continue
|
||||
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if name in self._replication_slots and compare_slots(value, self._replication_slots[name]):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
if 'lsn' in value and value['confirmed_flush_lsn'] < value['lsn']: # The slot has feedback in DCS
|
||||
# Skip slots that don't need to be advanced
|
||||
advance_slots[value['database']][name] = value['lsn']
|
||||
elif name not in self._replication_slots and 'lsn' in value:
|
||||
# We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
|
||||
# Slots to be copied from the primary should be removed from the *slots* structure,
|
||||
# otherwise Patroni falsely assumes that they already exist.
|
||||
for name in create_slots:
|
||||
slots.pop(name)
|
||||
|
||||
error, copy_slots = self.schedule_advance_slots(advance_slots)
|
||||
if error:
|
||||
self._schedule_load_slots = True
|
||||
return create_slots + copy_slots
|
||||
|
||||
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None, paused=False):
|
||||
ret = None
|
||||
if self._postgresql.major_version >= 90400 and cluster.config:
|
||||
def sync_replication_slots(self, cluster: Cluster, nofailover: bool,
|
||||
replicatefrom: Optional[str] = None, paused: bool = False) -> List[str]:
|
||||
"""During the HA loop read, check and alter replication slots found in the cluster.
|
||||
|
||||
Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS.
|
||||
Drop any slots that do not match those required by configuration and are not configured as permanent.
|
||||
Create any missing physical slots, or advance their position according to feedback stored in DCS.
|
||||
If we are the primary then create logical slots, otherwise if logical slots are known and active create
|
||||
them on replica nodes by copying slot files from the primary.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param nofailover: ``True`` if this node has been tagged to not be a failover candidate.
|
||||
:param replicatefrom: the tag containing the node to replicate from.
|
||||
:param paused: ``True`` if the cluster is in maintenance mode.
|
||||
|
||||
:returns: list of logical replication slots names that should be copied from the primary.
|
||||
"""
|
||||
ret = []
|
||||
if self._postgresql.major_version >= 90400 and self._postgresql.global_config and cluster.config:
|
||||
try:
|
||||
self.load_replication_slots()
|
||||
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role,
|
||||
nofailover, self._postgresql.major_version, True)
|
||||
slots = cluster.get_replication_slots(
|
||||
self._postgresql.name, self._postgresql.role, nofailover, self._postgresql.major_version,
|
||||
is_standby_cluster=self._postgresql.global_config.is_standby_cluster, show_error=True)
|
||||
|
||||
self._drop_incorrect_slots(cluster, slots, paused)
|
||||
|
||||
self._ensure_physical_slots(slots)
|
||||
|
||||
if self._postgresql.is_leader():
|
||||
self._unready_logical_slots.clear()
|
||||
if self._postgresql.is_primary():
|
||||
self._logical_slots_processing_queue.clear()
|
||||
self._ensure_logical_slots_primary(slots)
|
||||
elif cluster.slots and slots:
|
||||
self.check_logical_slots_readiness(cluster, nofailover, replicatefrom)
|
||||
|
||||
ret = self._ensure_logical_slots_replica(cluster, slots)
|
||||
else:
|
||||
self.check_logical_slots_readiness(cluster, replicatefrom)
|
||||
ret = self._ensure_logical_slots_replica(slots)
|
||||
|
||||
self._replication_slots = slots
|
||||
except Exception:
|
||||
@@ -312,56 +536,126 @@ class SlotsHandler(object):
|
||||
return ret
|
||||
|
||||
@contextmanager
|
||||
def _get_leader_connection_cursor(self, leader):
|
||||
def _get_leader_connection_cursor(self, leader: Leader) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
"""Create a new database connection to the leader.
|
||||
|
||||
.. note::
|
||||
Uses rewind user credentials because it has enough permissions to read files from PGDATA.
|
||||
Sets the options ``connect_timeout`` to ``3`` and ``statement_timeout`` to ``2000``.
|
||||
|
||||
:param leader: object with information on the leader
|
||||
|
||||
:yields: connection cursor object, note implementation varies depending on version of ``psycopg``.
|
||||
"""
|
||||
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
|
||||
conn_kwargs['dbname'] = self._postgresql.database
|
||||
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, nofailover, replicatefrom):
|
||||
if self._unready_logical_slots:
|
||||
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._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:
|
||||
for name, value in slots.items():
|
||||
self._unready_logical_slots[name] = value
|
||||
else: # Replica isn't streaming or the hot_standby_feedback isn't enabled
|
||||
try:
|
||||
cur = self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")
|
||||
if not cur.fetchone()[0]:
|
||||
logger.error('Logical slot failover requires "hot_standby_feedback".'
|
||||
' Please check postgresql.auto.conf')
|
||||
except Exception as e:
|
||||
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
|
||||
return # since `catalog_xmin` isn't valid further checks don't make any sense
|
||||
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 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:
|
||||
if not self._query("SELECT pg_catalog.current_setting('hot_standby_feedback')::boolean")[0][0]:
|
||||
logger.error('Logical slot failover requires "hot_standby_feedback". Please check postgresql.auto.conf')
|
||||
except Exception as e:
|
||||
logger.error('Failed to check the hot_standby_feedback setting: %r', e)
|
||||
return False
|
||||
|
||||
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, create_slots):
|
||||
def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None:
|
||||
"""Create logical replication slots on standby nodes.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param create_slots: list of slot names to copy from the primary.
|
||||
"""
|
||||
leader = cluster.leader
|
||||
if not leader:
|
||||
return
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version)
|
||||
copy_slots: Dict[str, Dict[str, Any]] = {}
|
||||
with self._get_leader_connection_cursor(leader) as cur:
|
||||
try:
|
||||
cur.execute("SELECT slot_name, slot_type, datname, plugin, catalog_xmin, "
|
||||
@@ -370,48 +664,64 @@ class SlotsHandler(object):
|
||||
" FROM pg_catalog.pg_get_replication_slots() JOIN pg_catalog.pg_database ON datoid = oid"
|
||||
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = ANY(%s)", (create_slots,))
|
||||
|
||||
create_slots = {}
|
||||
for r in cur:
|
||||
if r[0] in slots: # slot_name is defined in the global configuration
|
||||
slot = {'type': r[1], 'database': r[2], 'plugin': r[3],
|
||||
'catalog_xmin': r[4], 'confirmed_flush_lsn': r[5], 'data': r[6]}
|
||||
if compare_slots(slot, slots[r[0]]):
|
||||
create_slots[r[0]] = slot
|
||||
copy_slots[r[0]] = slot
|
||||
else:
|
||||
logger.warning('Will not copy the logical slot "%s" due to the configuration mismatch: ' +
|
||||
logger.warning('Will not copy the logical slot "%s" due to the configuration mismatch: '
|
||||
'configuration=%s, slot on the primary=%s', r[0], slots[r[0]], slot)
|
||||
except Exception as e:
|
||||
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
|
||||
|
||||
if isinstance(create_slots, dict) and create_slots and self._postgresql.stop():
|
||||
for name, value in create_slots.items():
|
||||
slot_dir = os.path.join(self._postgresql.slots_handler.pg_replslot_dir, name)
|
||||
if copy_slots and self._postgresql.stop():
|
||||
pg_perm.set_permissions_from_data_directory(self._postgresql.data_dir)
|
||||
for name, value in copy_slots.items():
|
||||
slot_dir = os.path.join(self.pg_replslot_dir, name)
|
||||
slot_tmp_dir = slot_dir + '.tmp'
|
||||
if os.path.exists(slot_tmp_dir):
|
||||
shutil.rmtree(slot_tmp_dir)
|
||||
os.makedirs(slot_tmp_dir)
|
||||
os.chmod(slot_tmp_dir, pg_perm.dir_create_mode)
|
||||
fsync_dir(slot_tmp_dir)
|
||||
with open(os.path.join(slot_tmp_dir, 'state'), 'wb') as f:
|
||||
slot_filename = os.path.join(slot_tmp_dir, 'state')
|
||||
with open(slot_filename, 'wb') as f:
|
||||
os.chmod(slot_filename, pg_perm.file_create_mode)
|
||||
f.write(value['data'])
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if os.path.exists(slot_dir):
|
||||
shutil.rmtree(slot_dir)
|
||||
os.rename(slot_tmp_dir, slot_dir)
|
||||
os.chmod(slot_dir, pg_perm.dir_create_mode)
|
||||
fsync_dir(slot_dir)
|
||||
self._unready_logical_slots[name] = None
|
||||
fsync_dir(self._postgresql.slots_handler.pg_replslot_dir)
|
||||
self._logical_slots_processing_queue[name] = None
|
||||
fsync_dir(self.pg_replslot_dir)
|
||||
self._postgresql.start()
|
||||
|
||||
def schedule(self, value=None):
|
||||
def schedule(self, value: Optional[bool] = None) -> None:
|
||||
"""Schedule the loading of slot information from the database.
|
||||
|
||||
:param value: the optional value can be used to unschedule if set to ``False`` or force it to be ``True``.
|
||||
If it is omitted the value will be ``True`` if this PostgreSQL node supports slot replication.
|
||||
"""
|
||||
if value is None:
|
||||
value = self._postgresql.major_version >= 90400
|
||||
self._schedule_load_slots = self._force_readiness_check = value
|
||||
|
||||
def on_promote(self):
|
||||
def on_promote(self) -> None:
|
||||
"""Entry point from HA cycle used when a standby node is to be promoted to primary.
|
||||
|
||||
.. note::
|
||||
If logical replication slot synchronisation is enabled then slot advancement will be triggered.
|
||||
If any logical slots that were copied are yet to be confirmed as ready a warning message will be logged.
|
||||
|
||||
"""
|
||||
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))
|
||||
|
||||
+199
-105
@@ -3,9 +3,13 @@ import re
|
||||
import time
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Collection, List, NamedTuple, Tuple, TYPE_CHECKING
|
||||
|
||||
from .validator import CaseInsensitiveDict
|
||||
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||
from ..dcs import Cluster
|
||||
from ..psycopg import quote_ident as _quote_ident
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from . import Postgresql
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,45 +27,59 @@ SYNC_REP_PARSER_RE = re.compile(r"""
|
||||
| (?P<parenend> \) )
|
||||
| (?P<JUNK> . )
|
||||
""", re.X)
|
||||
_EMPTY_SSN = {'type': 'off', 'num': 0, 'members': CaseInsensitiveDict({})}
|
||||
|
||||
|
||||
def quote_ident(value):
|
||||
"""Very simplified version of quote_ident"""
|
||||
def quote_ident(value: str) -> str:
|
||||
"""Very simplified version of `psycopg` :func:`quote_ident` function."""
|
||||
return value if SYNC_STANDBY_NAME_RE.match(value) else _quote_ident(value)
|
||||
|
||||
|
||||
def parse_sync_standby_names(value):
|
||||
"""Parse postgresql synchronous_standby_names to constituent parts.
|
||||
Returns dict with the following keys:
|
||||
* type: 'quorum'|'priority'
|
||||
* num: int
|
||||
* members: CaseInsensitiveDict, with names as keys
|
||||
* has_star: bool - Present if true
|
||||
If the configuration value can not be parsed, raises a ValueError.
|
||||
class _SSN(NamedTuple):
|
||||
"""class representing "synchronous_standby_names" value after parsing.
|
||||
|
||||
>>> parse_sync_standby_names('')['type']
|
||||
:ivar sync_type: possible values: 'off', 'priority', 'quorum'
|
||||
:ivar has_star: is set to `True` if "synchronous_standby_names" contains '*'
|
||||
:ivar num: how many nodes are required to be synchronous
|
||||
:ivar members: collection of standby names listed in "synchronous_standby_names"
|
||||
"""
|
||||
sync_type: str
|
||||
has_star: bool
|
||||
num: int
|
||||
members: CaseInsensitiveSet
|
||||
|
||||
|
||||
_EMPTY_SSN = _SSN('off', False, 0, CaseInsensitiveSet())
|
||||
|
||||
|
||||
def parse_sync_standby_names(value: str) -> _SSN:
|
||||
"""Parse postgresql synchronous_standby_names to constituent parts.
|
||||
|
||||
:param value: the value of `synchronous_standby_names`
|
||||
:returns: :class:`_SSN` object
|
||||
:raises `ValueError`: if the configuration value can not be parsed
|
||||
|
||||
>>> parse_sync_standby_names('').sync_type
|
||||
'off'
|
||||
|
||||
>>> parse_sync_standby_names('FiRsT')['type']
|
||||
>>> parse_sync_standby_names('FiRsT').sync_type
|
||||
'priority'
|
||||
|
||||
>>> parse_sync_standby_names('FiRsT')['members']
|
||||
{'FiRsT': True}
|
||||
|
||||
>>> parse_sync_standby_names('"1"')['members']
|
||||
{'1': True}
|
||||
|
||||
>>> parse_sync_standby_names(' a , b ')['members']
|
||||
{'a': True, 'b': True}
|
||||
|
||||
>>> parse_sync_standby_names(' a , b ')['num']
|
||||
1
|
||||
|
||||
>>> parse_sync_standby_names('ANY 4("a",*,b)')['has_star']
|
||||
>>> 'first' in parse_sync_standby_names('FiRsT').members
|
||||
True
|
||||
|
||||
>>> parse_sync_standby_names('ANY 4("a",*,b)')['num']
|
||||
>>> set(parse_sync_standby_names('"1"').members)
|
||||
{'1'}
|
||||
|
||||
>>> parse_sync_standby_names(' a , b ').members == {'a', 'b'}
|
||||
True
|
||||
|
||||
>>> parse_sync_standby_names(' a , b ').num
|
||||
1
|
||||
|
||||
>>> parse_sync_standby_names('ANY 4("a",*,b)').has_star
|
||||
True
|
||||
|
||||
>>> parse_sync_standby_names('ANY 4("a",*,b)').num
|
||||
4
|
||||
|
||||
>>> parse_sync_standby_names('1') # doctest: +IGNORE_EXCEPTION_DETAIL
|
||||
@@ -96,18 +114,24 @@ def parse_sync_standby_names(value):
|
||||
return deepcopy(_EMPTY_SSN)
|
||||
|
||||
if [t[0] for t in tokens[0:3]] == ['any', 'num', 'parenstart'] and tokens[-1][0] == 'parenend':
|
||||
result = {'type': 'quorum', 'num': int(tokens[1][1])}
|
||||
sync_type = 'quorum'
|
||||
num = int(tokens[1][1])
|
||||
synclist = tokens[3:-1]
|
||||
elif [t[0] for t in tokens[0:3]] == ['first', 'num', 'parenstart'] and tokens[-1][0] == 'parenend':
|
||||
result = {'type': 'priority', 'num': int(tokens[1][1])}
|
||||
sync_type = 'priority'
|
||||
num = int(tokens[1][1])
|
||||
synclist = tokens[3:-1]
|
||||
elif [t[0] for t in tokens[0:2]] == ['num', 'parenstart'] and tokens[-1][0] == 'parenend':
|
||||
result = {'type': 'priority', 'num': int(tokens[0][1])}
|
||||
sync_type = 'priority'
|
||||
num = int(tokens[0][1])
|
||||
synclist = tokens[2:-1]
|
||||
else:
|
||||
result = {'type': 'priority', 'num': 1}
|
||||
sync_type = 'priority'
|
||||
num = 1
|
||||
synclist = tokens
|
||||
result['members'] = CaseInsensitiveDict({})
|
||||
|
||||
has_star = False
|
||||
members = CaseInsensitiveSet()
|
||||
for i, (a_type, a_value, a_pos) in enumerate(synclist):
|
||||
if i % 2 == 1: # odd elements are supposed to be commas
|
||||
if len(synclist) == i + 1: # except the last token
|
||||
@@ -117,16 +141,82 @@ def parse_sync_standby_names(value):
|
||||
raise ValueError("Unparseable synchronous_standby_names value %r: ""Got token %s %r while"
|
||||
" expecting comma at %d" % (value, a_type, a_value, a_pos))
|
||||
elif a_type in {'ident', 'first', 'any'}:
|
||||
result['members'][a_value] = True
|
||||
members.add(a_value)
|
||||
elif a_type == 'star':
|
||||
result['members'][a_value] = True
|
||||
result['has_star'] = True
|
||||
members.add(a_value)
|
||||
has_star = True
|
||||
elif a_type == 'dquot':
|
||||
result['members'][a_value[1:-1].replace('""', '"')] = True
|
||||
members.add(a_value[1:-1].replace('""', '"'))
|
||||
else:
|
||||
raise ValueError("Unparseable synchronous_standby_names value %r: Unexpected token %s %r at %d" %
|
||||
(value, a_type, a_value, a_pos))
|
||||
return result
|
||||
return _SSN(sync_type, has_star, num, members)
|
||||
|
||||
|
||||
class _Replica(NamedTuple):
|
||||
"""Class representing a single replica that is eligible to be synchronous.
|
||||
|
||||
Attributes are taken from ``pg_stat_replication`` view and respective ``Cluster.members``.
|
||||
|
||||
:ivar pid: PID of walsender process.
|
||||
:ivar application_name: matches with the ``Member.name``.
|
||||
:ivar sync_state: possible values are: ``async``, ``potential``, ``quorum``, and ``sync``.
|
||||
:ivar lsn: ``write_lsn``, ``flush_lsn``, or ``replay_lsn``, depending on the value of ``synchronous_commit`` GUC.
|
||||
:ivar nofailover: whether the corresponding member has ``nofailover`` tag set to ``True``.
|
||||
"""
|
||||
pid: int
|
||||
application_name: str
|
||||
sync_state: str
|
||||
lsn: int
|
||||
nofailover: bool
|
||||
|
||||
|
||||
class _ReplicaList(List[_Replica]):
|
||||
"""A collection of :class:``_Replica`` objects.
|
||||
|
||||
Values are reverse ordered by ``_Replica.sync_state`` and ``_Replica.lsn``.
|
||||
That is, first there will be replicas that have ``sync_state`` == ``sync``, even if they are not
|
||||
the most up-to-date in term of write/flush/replay LSN. It helps to keep the result of chosing new
|
||||
synchronous nodes consistent in case if a synchronous standby member is slowed down OR async node
|
||||
is receiving changes faster than the sync member. Such cases would trigger sync standby member
|
||||
swapping, but only if lag on this member is exceeding a threshold (``maximum_lag_on_syncnode``).
|
||||
|
||||
:ivar max_lsn: maximum value of ``_Replica.lsn`` among all values. In case if there is just one
|
||||
element in the list we take value of ``pg_current_wal_flush_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.nosync:
|
||||
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):
|
||||
@@ -137,7 +227,7 @@ class SyncHandler(object):
|
||||
and the `current_state()` method will count newly added names as "sync" only when
|
||||
they reached memorized LSN and also reported as "sync" by `pg_stat_replication`"""
|
||||
|
||||
def __init__(self, postgresql):
|
||||
def __init__(self, postgresql: 'Postgresql') -> None:
|
||||
self._postgresql = postgresql
|
||||
self._synchronous_standby_names = '' # last known value of synchronous_standby_names
|
||||
self._ssn_data = deepcopy(_EMPTY_SSN)
|
||||
@@ -145,12 +235,15 @@ class SyncHandler(object):
|
||||
# "sync" replication connections, that were verified to reach self._primary_flush_lsn at some point
|
||||
self._ready_replicas = CaseInsensitiveDict({}) # keys: member names, values: connection pids
|
||||
|
||||
def _handle_synchronous_standby_names_change(self):
|
||||
"""If synchronous_standby_names has changed we need to check that newly added replicas
|
||||
have reached self._primary_flush_lsn. Only after that they could be counted as sync."""
|
||||
def _handle_synchronous_standby_names_change(self) -> None:
|
||||
"""Handles changes of "synchronous_standby_names" GUC.
|
||||
|
||||
If "synchronous_standby_names" was changed, we need to check that newly added replicas have
|
||||
reached `self._primary_flush_lsn`. Only after that they could be counted as synchronous.
|
||||
"""
|
||||
synchronous_standby_names = self._postgresql.synchronous_standby_names()
|
||||
if synchronous_standby_names == self._synchronous_standby_names:
|
||||
return False
|
||||
return
|
||||
|
||||
self._synchronous_standby_names = synchronous_standby_names
|
||||
try:
|
||||
@@ -161,91 +254,92 @@ class SyncHandler(object):
|
||||
|
||||
# Invalidate cache of "sync" connections
|
||||
for app_name in list(self._ready_replicas.keys()):
|
||||
if app_name not in self._ssn_data['members']:
|
||||
if app_name not in self._ssn_data.members:
|
||||
del self._ready_replicas[app_name]
|
||||
|
||||
# Newly connected replicas will be counted as sync only when reached self._primary_flush_lsn
|
||||
self._primary_flush_lsn = self._postgresql.last_operation()
|
||||
self._postgresql.query('SELECT pg_catalog.txid_current()') # Ensure some WAL traffic to move replication
|
||||
# Ensure some WAL traffic to move replication
|
||||
self._postgresql.query("""DO $$
|
||||
BEGIN
|
||||
SET local synchronous_commit = 'off';
|
||||
PERFORM * FROM pg_catalog.txid_current();
|
||||
END;$$""")
|
||||
self._postgresql.reset_cluster_info_state(None) # Reset internal cache to query fresh values
|
||||
|
||||
def current_state(self, cluster, sync_node_count=1, sync_node_maxlag=-1):
|
||||
"""Finds best candidates to be the synchronous standbys.
|
||||
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]:
|
||||
"""Find the best candidates to be the synchronous standbys.
|
||||
|
||||
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
||||
synchronous standby any longer.
|
||||
Parameter sync_node_maxlag(maximum_lag_on_syncnode) would help swapping unhealthy sync replica in case
|
||||
if it stops responding (or hung). Please set the value high enough so it won't unncessarily swap sync
|
||||
standbys during high loads. Any less or equal of 0 value keep the behavior backward compatible and
|
||||
will not swap. Please note that it will not also swap sync standbys in case where all replicas are hung.
|
||||
|
||||
:returns: tuple of candidates list and synchronous standby list."""
|
||||
Standbys are selected based on values from the global configuration:
|
||||
|
||||
- `maximum_lag_on_syncnode`: would help swapping unhealthy sync replica in case if it stops
|
||||
responding (or hung). Please set the value high enough so it won't unncessarily swap sync
|
||||
standbys during high loads. Any value less or equal of 0 keeps the behavior backward compatible.
|
||||
Please note that it will not also swap sync standbys in case where all replicas are hung.
|
||||
- `synchronous_node_count`: controlls how many nodes should be set as synchronous.
|
||||
|
||||
:returns: tuple of candidates :class:`CaseInsensitiveSet` and synchronous standbys :class:`CaseInsensitiveSet`.
|
||||
"""
|
||||
self._handle_synchronous_standby_names_change()
|
||||
|
||||
# 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'
|
||||
replica_list = _ReplicaList(self._postgresql, cluster)
|
||||
self._process_replica_readiness(cluster, replica_list)
|
||||
|
||||
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]
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert self._postgresql.global_config is not None
|
||||
sync_node_count = self._postgresql.global_config.synchronous_node_count\
|
||||
if self._postgresql.supports_multiple_sync else 1
|
||||
sync_node_maxlag = self._postgresql.global_config.maximum_lag_on_syncnode
|
||||
|
||||
members = CaseInsensitiveDict({m.name: m for m in cluster.members})
|
||||
replica_list = []
|
||||
# pg_stat_replication.sync_state has 4 possible states - async, potential, quorum, sync.
|
||||
# That is, alphabetically they are in the reversed order of priority.
|
||||
# Since we are doing reversed sort on (sync_state, lsn) tuples, it helps to keep the result
|
||||
# consistent in case if a synchronous standby member is slowed down OR async node receiving
|
||||
# changes faster than the sync member (very rare but possible).
|
||||
# Such cases would trigger sync standby member swapping, but only if lag on a sync node exceeding a threshold.
|
||||
for pid, app_name, sync_state, replica_lsn in sorted(pg_stat_replication, key=lambda r: r[2:4], reverse=True):
|
||||
member = members.get(app_name)
|
||||
if member and member.is_running and not member.tags.get('nosync', False):
|
||||
replica_list.append((pid, member.name, sync_state, replica_lsn, bool(member.nofailover)))
|
||||
|
||||
max_lsn = max(replica_list, key=lambda x: x[3])[3]\
|
||||
if len(replica_list) > 1 else self._postgresql.last_operation()
|
||||
|
||||
if self._postgresql.major_version < 90600:
|
||||
sync_node_count = 1
|
||||
|
||||
candidates = []
|
||||
sync_nodes = []
|
||||
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
|
||||
# ig 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 and app_name in cluster.sync.members 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.append(app_name)
|
||||
if sync_state == 'sync' and app_name in self._ready_replicas:
|
||||
sync_nodes.append(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
|
||||
|
||||
return candidates, sync_nodes
|
||||
|
||||
def set_synchronous_standby_names(self, value):
|
||||
"""Constructs and sets `synchronous_standby_names` value.
|
||||
def set_synchronous_standby_names(self, sync: Collection[str]) -> None:
|
||||
"""Constructs and sets "synchronous_standby_names" GUC value.
|
||||
|
||||
:param value: list[str] - the list of wanted sync members"""
|
||||
if value and value != ['*']:
|
||||
value = [quote_ident(x) for x in value]
|
||||
|
||||
if self._postgresql.major_version >= 90600 and len(value) > 1:
|
||||
sync_param = '{0} ({1})'.format(len(value), ','.join(value))
|
||||
:param sync: set of nodes to sync to
|
||||
"""
|
||||
has_asterisk = '*' in sync
|
||||
if has_asterisk:
|
||||
sync = ['*']
|
||||
else:
|
||||
sync_param = next(iter(value), None)
|
||||
sync = [quote_ident(x) for x in sync]
|
||||
|
||||
if not (self._postgresql.config.set_synchronous_standby_names(sync_param) and
|
||||
self._postgresql.state == 'running' and self._postgresql.is_leader()) or value == ['*']:
|
||||
if self._postgresql.supports_multiple_sync and len(sync) > 1:
|
||||
sync_param = '{0} ({1})'.format(len(sync), ','.join(sync))
|
||||
else:
|
||||
sync_param = next(iter(sync), None)
|
||||
|
||||
if not (self._postgresql.config.set_synchronous_standby_names(sync_param)
|
||||
and self._postgresql.state == 'running' and self._postgresql.is_primary()) or has_asterisk:
|
||||
return
|
||||
|
||||
time.sleep(0.1) # Usualy it takes 1ms to reload postgresql.conf, but we will give it 100ms
|
||||
@@ -253,6 +347,6 @@ class SyncHandler(object):
|
||||
# Reset internal cache to query fresh values
|
||||
self._postgresql.reset_cluster_info_state(None)
|
||||
|
||||
# timeline == 0 -- indicates that this is the replica, shoudn't ever happen
|
||||
# timeline == 0 -- indicates that this is the replica
|
||||
if self._postgresql.get_primary_timeline() > 0:
|
||||
self._handle_synchronous_standby_names_change()
|
||||
|
||||
+434
-465
@@ -1,48 +1,87 @@
|
||||
import abc
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
import six
|
||||
import os
|
||||
import yaml
|
||||
|
||||
from collections import namedtuple
|
||||
from urllib3.response import HTTPHeaderDict
|
||||
from typing import Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Type, Union
|
||||
|
||||
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||
from ..exceptions import PatroniException
|
||||
from ..utils import parse_bool, parse_int, parse_real
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CaseInsensitiveDict(HTTPHeaderDict):
|
||||
class _Transformable(abc.ABC):
|
||||
|
||||
def add(self, key, val):
|
||||
self[key] = val
|
||||
def __init__(self, version_from: int, version_till: Optional[int] = None) -> None:
|
||||
self.__version_from = version_from
|
||||
self.__version_till = version_till
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._container[key.lower()][1]
|
||||
@classmethod
|
||||
def get_subclasses(cls) -> Iterator[Type['_Transformable']]:
|
||||
"""Recursively get all subclasses of :class:`_Transformable`.
|
||||
|
||||
def __repr__(self):
|
||||
return str(dict(self.items()))
|
||||
:yields: each subclass of :class:`_Transformable`.
|
||||
"""
|
||||
for subclass in cls.__subclasses__():
|
||||
yield from subclass.get_subclasses()
|
||||
yield subclass
|
||||
|
||||
def copy(self):
|
||||
return CaseInsensitiveDict(self._container.values())
|
||||
@property
|
||||
def version_from(self) -> int:
|
||||
return self.__version_from
|
||||
|
||||
@property
|
||||
def version_till(self) -> Optional[int]:
|
||||
return self.__version_till
|
||||
|
||||
@abc.abstractmethod
|
||||
def transform(self, name: str, value: Any) -> Optional[Any]:
|
||||
"""Verify that provided value is valid.
|
||||
|
||||
:param name: GUC's name
|
||||
:param value: GUC's value
|
||||
:returns: the value (sometimes clamped) or ``None`` if the value isn't valid
|
||||
"""
|
||||
|
||||
|
||||
class Bool(namedtuple('Bool', 'version_from,version_till')):
|
||||
class Bool(_Transformable):
|
||||
|
||||
@staticmethod
|
||||
def transform(name, value):
|
||||
def transform(self, name: str, value: Any) -> Optional[Any]:
|
||||
if parse_bool(value) is not None:
|
||||
return value
|
||||
logger.warning('Removing bool parameter=%s from the config due to the invalid value=%s', name, value)
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class Number(namedtuple('Number', 'version_from,version_till,min_val,max_val,unit')):
|
||||
class Number(_Transformable):
|
||||
|
||||
def __init__(self, *, version_from: int, version_till: Optional[int] = None, min_val: Union[int, float],
|
||||
max_val: Union[int, float], unit: Optional[str] = None) -> None:
|
||||
super(Number, self).__init__(version_from, version_till)
|
||||
self.__min_val = min_val
|
||||
self.__max_val = max_val
|
||||
self.__unit = unit
|
||||
|
||||
@property
|
||||
def min_val(self) -> Union[int, float]:
|
||||
return self.__min_val
|
||||
|
||||
@property
|
||||
def max_val(self) -> Union[int, float]:
|
||||
return self.__max_val
|
||||
|
||||
@property
|
||||
def unit(self) -> Optional[str]:
|
||||
return self.__unit
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def parse(value, unit):
|
||||
"""parse value"""
|
||||
def parse(value: Any, unit: Optional[str]) -> Optional[Any]:
|
||||
"""Convert provided value to unit."""
|
||||
|
||||
def transform(self, name, value):
|
||||
def transform(self, name: str, value: Any) -> Union[int, float, None]:
|
||||
num_value = self.parse(value, self.unit)
|
||||
if num_value is not None:
|
||||
if num_value < self.min_val:
|
||||
@@ -61,20 +100,29 @@ class Number(namedtuple('Number', 'version_from,version_till,min_val,max_val,uni
|
||||
class Integer(Number):
|
||||
|
||||
@staticmethod
|
||||
def parse(value, unit):
|
||||
def parse(value: Any, unit: Optional[str]) -> Optional[int]:
|
||||
return parse_int(value, unit)
|
||||
|
||||
|
||||
class Real(Number):
|
||||
|
||||
@staticmethod
|
||||
def parse(value, unit):
|
||||
def parse(value: Any, unit: Optional[str]) -> Optional[float]:
|
||||
return parse_real(value, unit)
|
||||
|
||||
|
||||
class Enum(namedtuple('Enum', 'version_from,version_till,possible_values')):
|
||||
class Enum(_Transformable):
|
||||
|
||||
def transform(self, name, value):
|
||||
def __init__(self, *, version_from: int, version_till: Optional[int] = None,
|
||||
possible_values: Tuple[str, ...]) -> None:
|
||||
super(Enum, self).__init__(version_from, version_till)
|
||||
self.__possible_values = possible_values
|
||||
|
||||
@property
|
||||
def possible_values(self) -> Tuple[str, ...]:
|
||||
return self.__possible_values
|
||||
|
||||
def transform(self, name: str, value: Optional[Any]) -> Optional[Any]:
|
||||
if str(value).lower() in self.possible_values:
|
||||
return value
|
||||
logger.warning('Removing enum parameter=%s from the config due to the invalid value=%s', name, value)
|
||||
@@ -82,469 +130,390 @@ class Enum(namedtuple('Enum', 'version_from,version_till,possible_values')):
|
||||
|
||||
class EnumBool(Enum):
|
||||
|
||||
def transform(self, name, value):
|
||||
def transform(self, name: str, value: Optional[Any]) -> Optional[Any]:
|
||||
if parse_bool(value) is not None:
|
||||
return value
|
||||
return super(EnumBool, self).transform(name, value)
|
||||
|
||||
|
||||
class String(namedtuple('String', 'version_from,version_till')):
|
||||
class String(_Transformable):
|
||||
|
||||
@staticmethod
|
||||
def transform(name, value):
|
||||
def transform(self, name: str, value: Optional[Any]) -> Optional[Any]:
|
||||
return value
|
||||
|
||||
|
||||
# Format:
|
||||
# key - parameter name
|
||||
# value - tuple or multiple tuples if something was changing in GUC across postgres versions
|
||||
parameters = CaseInsensitiveDict({
|
||||
'allow_in_place_tablespaces': Bool(150000, None),
|
||||
'allow_system_table_mods': Bool(90300, None),
|
||||
'application_name': String(90300, None),
|
||||
'archive_command': String(90300, None),
|
||||
'archive_library': String(150000, None),
|
||||
'archive_mode': (
|
||||
Bool(90300, 90500),
|
||||
EnumBool(90500, None, ('always',))
|
||||
),
|
||||
'archive_timeout': Integer(90300, None, 0, 1073741823, 's'),
|
||||
'array_nulls': Bool(90300, None),
|
||||
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
|
||||
'autovacuum': Bool(90300, None),
|
||||
'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None),
|
||||
'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None),
|
||||
'autovacuum_freeze_max_age': Integer(90300, None, 100000, 2000000000, None),
|
||||
'autovacuum_max_workers': (
|
||||
Integer(90300, 90600, 1, 8388607, None),
|
||||
Integer(90600, None, 1, 262143, None)
|
||||
),
|
||||
'autovacuum_multixact_freeze_max_age': Integer(90300, None, 10000, 2000000000, None),
|
||||
'autovacuum_naptime': Integer(90300, None, 1, 2147483, 's'),
|
||||
'autovacuum_vacuum_cost_delay': (
|
||||
Integer(90300, 120000, -1, 100, 'ms'),
|
||||
Real(120000, None, -1, 100, 'ms')
|
||||
),
|
||||
'autovacuum_vacuum_cost_limit': Integer(90300, None, -1, 10000, None),
|
||||
'autovacuum_vacuum_insert_scale_factor': Real(130000, None, 0, 100, None),
|
||||
'autovacuum_vacuum_insert_threshold': Integer(130000, None, -1, 2147483647, None),
|
||||
'autovacuum_vacuum_scale_factor': Real(90300, None, 0, 100, None),
|
||||
'autovacuum_vacuum_threshold': Integer(90300, None, 0, 2147483647, None),
|
||||
'autovacuum_work_mem': Integer(90400, None, -1, 2147483647, 'kB'),
|
||||
'backend_flush_after': Integer(90600, None, 0, 256, '8kB'),
|
||||
'backslash_quote': EnumBool(90300, None, ('safe_encoding',)),
|
||||
'backtrace_functions': String(130000, None),
|
||||
'bgwriter_delay': Integer(90300, None, 10, 10000, 'ms'),
|
||||
'bgwriter_flush_after': Integer(90600, None, 0, 256, '8kB'),
|
||||
'bgwriter_lru_maxpages': (
|
||||
Integer(90300, 100000, 0, 1000, None),
|
||||
Integer(100000, None, 0, 1073741823, None)
|
||||
),
|
||||
'bgwriter_lru_multiplier': Real(90300, None, 0, 10, None),
|
||||
'bonjour': Bool(90300, None),
|
||||
'bonjour_name': String(90300, None),
|
||||
'bytea_output': Enum(90300, None, ('escape', 'hex')),
|
||||
'check_function_bodies': Bool(90300, None),
|
||||
'checkpoint_completion_target': Real(90300, None, 0, 1, None),
|
||||
'checkpoint_flush_after': Integer(90600, None, 0, 256, '8kB'),
|
||||
'checkpoint_segments': Integer(90300, 90500, 1, 2147483647, None),
|
||||
'checkpoint_timeout': (
|
||||
Integer(90300, 90600, 30, 3600, 's'),
|
||||
Integer(90600, None, 30, 86400, 's')
|
||||
),
|
||||
'checkpoint_warning': Integer(90300, None, 0, 2147483647, 's'),
|
||||
'client_connection_check_interval': Integer(140000, None, 0, 2147483647, 'ms'),
|
||||
'client_encoding': String(90300, None),
|
||||
'client_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
|
||||
'debug1', 'log', 'notice', 'warning', 'error')),
|
||||
'cluster_name': String(90500, None),
|
||||
'commit_delay': Integer(90300, None, 0, 100000, None),
|
||||
'commit_siblings': Integer(90300, None, 0, 1000, None),
|
||||
'compute_query_id': (
|
||||
EnumBool(140000, 150000, ('auto',)),
|
||||
EnumBool(150000, None, ('auto', 'regress'))
|
||||
),
|
||||
'config_file': String(90300, None),
|
||||
'constraint_exclusion': EnumBool(90300, None, ('partition',)),
|
||||
'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'cpu_operator_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'cpu_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'cursor_tuple_fraction': Real(90300, None, 0, 1, None),
|
||||
'data_directory': String(90300, None),
|
||||
'data_sync_retry': Bool(90400, None),
|
||||
'DateStyle': String(90300, None),
|
||||
'db_user_namespace': Bool(90300, None),
|
||||
'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'),
|
||||
'debug_discard_caches': Integer(150000, None, 0, 0, None),
|
||||
'debug_pretty_print': Bool(90300, None),
|
||||
'debug_print_parse': Bool(90300, None),
|
||||
'debug_print_plan': Bool(90300, None),
|
||||
'debug_print_rewritten': Bool(90300, None),
|
||||
'default_statistics_target': Integer(90300, None, 1, 10000, None),
|
||||
'default_table_access_method': String(120000, None),
|
||||
'default_tablespace': String(90300, None),
|
||||
'default_text_search_config': String(90300, None),
|
||||
'default_toast_compression': Enum(140000, None, ('pglz', 'lz4')),
|
||||
'default_transaction_deferrable': Bool(90300, None),
|
||||
'default_transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
|
||||
'read committed', 'read uncommitted')),
|
||||
'default_transaction_read_only': Bool(90300, None),
|
||||
'default_with_oids': Bool(90300, 120000),
|
||||
'dynamic_library_path': String(90300, None),
|
||||
'dynamic_shared_memory_type': (
|
||||
Enum(90400, 120000, ('posix', 'sysv', 'mmap', 'none')),
|
||||
Enum(120000, None, ('posix', 'sysv', 'mmap'))
|
||||
),
|
||||
'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'),
|
||||
'effective_io_concurrency': Integer(90300, None, 0, 1000, None),
|
||||
'enable_async_append': Bool(140000, None),
|
||||
'enable_bitmapscan': Bool(90300, None),
|
||||
'enable_gathermerge': Bool(100000, None),
|
||||
'enable_hashagg': Bool(90300, None),
|
||||
'enable_hashjoin': Bool(90300, None),
|
||||
'enable_incremental_sort': Bool(130000, None),
|
||||
'enable_indexonlyscan': Bool(90300, None),
|
||||
'enable_indexscan': Bool(90300, None),
|
||||
'enable_material': Bool(90300, None),
|
||||
'enable_memoize': Bool(150000, None),
|
||||
'enable_mergejoin': Bool(90300, None),
|
||||
'enable_nestloop': Bool(90300, None),
|
||||
'enable_parallel_append': Bool(110000, None),
|
||||
'enable_parallel_hash': Bool(110000, None),
|
||||
'enable_partition_pruning': Bool(110000, None),
|
||||
'enable_partitionwise_aggregate': Bool(110000, None),
|
||||
'enable_partitionwise_join': Bool(110000, None),
|
||||
'enable_seqscan': Bool(90300, None),
|
||||
'enable_sort': Bool(90300, None),
|
||||
'enable_tidscan': Bool(90300, None),
|
||||
'escape_string_warning': Bool(90300, None),
|
||||
'event_source': String(90300, None),
|
||||
'exit_on_error': Bool(90300, None),
|
||||
'extension_destdir': String(140000, None),
|
||||
'external_pid_file': String(90300, None),
|
||||
'extra_float_digits': Integer(90300, None, -15, 3, None),
|
||||
'force_parallel_mode': EnumBool(90600, None, ('regress',)),
|
||||
'from_collapse_limit': Integer(90300, None, 1, 2147483647, None),
|
||||
'fsync': Bool(90300, None),
|
||||
'full_page_writes': Bool(90300, None),
|
||||
'geqo': Bool(90300, None),
|
||||
'geqo_effort': Integer(90300, None, 1, 10, None),
|
||||
'geqo_generations': Integer(90300, None, 0, 2147483647, None),
|
||||
'geqo_pool_size': Integer(90300, None, 0, 2147483647, None),
|
||||
'geqo_seed': Real(90300, None, 0, 1, None),
|
||||
'geqo_selection_bias': Real(90300, None, 1.5, 2, None),
|
||||
'geqo_threshold': Integer(90300, None, 2, 2147483647, None),
|
||||
'gin_fuzzy_search_limit': Integer(90300, None, 0, 2147483647, None),
|
||||
'gin_pending_list_limit': Integer(90500, None, 64, 2147483647, 'kB'),
|
||||
'hash_mem_multiplier': Real(130000, None, 1, 1000, None),
|
||||
'hba_file': String(90300, None),
|
||||
'hot_standby': Bool(90300, None),
|
||||
'hot_standby_feedback': Bool(90300, None),
|
||||
'huge_pages': EnumBool(90400, None, ('try',)),
|
||||
'huge_page_size': Integer(140000, None, 0, 2147483647, 'kB'),
|
||||
'ident_file': String(90300, None),
|
||||
'idle_in_transaction_session_timeout': Integer(90600, None, 0, 2147483647, 'ms'),
|
||||
'idle_session_timeout': Integer(140000, None, 0, 2147483647, 'ms'),
|
||||
'ignore_checksum_failure': Bool(90300, None),
|
||||
'ignore_invalid_pages': Bool(130000, None),
|
||||
'ignore_system_indexes': Bool(90300, None),
|
||||
'IntervalStyle': Enum(90300, None, ('postgres', 'postgres_verbose', 'sql_standard', 'iso_8601')),
|
||||
'jit': Bool(110000, None),
|
||||
'jit_above_cost': Real(110000, None, -1, 1.79769e+308, None),
|
||||
'jit_debugging_support': Bool(110000, None),
|
||||
'jit_dump_bitcode': Bool(110000, None),
|
||||
'jit_expressions': Bool(110000, None),
|
||||
'jit_inline_above_cost': Real(110000, None, -1, 1.79769e+308, None),
|
||||
'jit_optimize_above_cost': Real(110000, None, -1, 1.79769e+308, None),
|
||||
'jit_profiling_support': Bool(110000, None),
|
||||
'jit_provider': String(110000, None),
|
||||
'jit_tuple_deforming': Bool(110000, None),
|
||||
'join_collapse_limit': Integer(90300, None, 1, 2147483647, None),
|
||||
'krb_caseins_users': Bool(90300, None),
|
||||
'krb_server_keyfile': String(90300, None),
|
||||
'krb_srvname': String(90300, 90400),
|
||||
'lc_messages': String(90300, None),
|
||||
'lc_monetary': String(90300, None),
|
||||
'lc_numeric': String(90300, None),
|
||||
'lc_time': String(90300, None),
|
||||
'listen_addresses': String(90300, None),
|
||||
'local_preload_libraries': String(90300, None),
|
||||
'lock_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
|
||||
'lo_compat_privileges': Bool(90300, None),
|
||||
'log_autovacuum_min_duration': Integer(90300, None, -1, 2147483647, 'ms'),
|
||||
'log_checkpoints': Bool(90300, None),
|
||||
'log_connections': Bool(90300, None),
|
||||
'log_destination': String(90300, None),
|
||||
'log_directory': String(90300, None),
|
||||
'log_disconnections': Bool(90300, None),
|
||||
'log_duration': Bool(90300, None),
|
||||
'log_error_verbosity': Enum(90300, None, ('terse', 'default', 'verbose')),
|
||||
'log_executor_stats': Bool(90300, None),
|
||||
'log_file_mode': Integer(90300, None, 0, 511, None),
|
||||
'log_filename': String(90300, None),
|
||||
'logging_collector': Bool(90300, None),
|
||||
'log_hostname': Bool(90300, None),
|
||||
'logical_decoding_work_mem': Integer(130000, None, 64, 2147483647, 'kB'),
|
||||
'log_line_prefix': String(90300, None),
|
||||
'log_lock_waits': Bool(90300, None),
|
||||
'log_min_duration_sample': Integer(130000, None, -1, 2147483647, 'ms'),
|
||||
'log_min_duration_statement': Integer(90300, None, -1, 2147483647, 'ms'),
|
||||
'log_min_error_statement': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'debug1', 'info',
|
||||
'notice', 'warning', 'error', 'log', 'fatal', 'panic')),
|
||||
'log_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'debug1', 'info',
|
||||
'notice', 'warning', 'error', 'log', 'fatal', 'panic')),
|
||||
'log_parameter_max_length': Integer(130000, None, -1, 1073741823, 'B'),
|
||||
'log_parameter_max_length_on_error': Integer(130000, None, -1, 1073741823, 'B'),
|
||||
'log_parser_stats': Bool(90300, None),
|
||||
'log_planner_stats': Bool(90300, None),
|
||||
'log_recovery_conflict_waits': Bool(140000, None),
|
||||
'log_replication_commands': Bool(90500, None),
|
||||
'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'),
|
||||
'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'),
|
||||
'log_startup_progress_interval': Integer(150000, None, 0, 2147483647, 'ms'),
|
||||
'log_statement': Enum(90300, None, ('none', 'ddl', 'mod', 'all')),
|
||||
'log_statement_sample_rate': Real(130000, None, 0, 1, None),
|
||||
'log_statement_stats': Bool(90300, None),
|
||||
'log_temp_files': Integer(90300, None, -1, 2147483647, 'kB'),
|
||||
'log_timezone': String(90300, None),
|
||||
'log_transaction_sample_rate': Real(120000, None, 0, 1, None),
|
||||
'log_truncate_on_rotation': Bool(90300, None),
|
||||
'maintenance_io_concurrency': Integer(130000, None, 0, 1000, None),
|
||||
'maintenance_work_mem': Integer(90300, None, 1024, 2147483647, 'kB'),
|
||||
'max_connections': (
|
||||
Integer(90300, 90600, 1, 8388607, None),
|
||||
Integer(90600, None, 1, 262143, None)
|
||||
),
|
||||
'max_files_per_process': (
|
||||
Integer(90300, 130000, 25, 2147483647, None),
|
||||
Integer(130000, None, 64, 2147483647, None)
|
||||
),
|
||||
'max_locks_per_transaction': Integer(90300, None, 10, 2147483647, None),
|
||||
'max_logical_replication_workers': Integer(100000, None, 0, 262143, None),
|
||||
'max_parallel_maintenance_workers': Integer(110000, None, 0, 1024, None),
|
||||
'max_parallel_workers': Integer(100000, None, 0, 1024, None),
|
||||
'max_parallel_workers_per_gather': Integer(90600, None, 0, 1024, None),
|
||||
'max_pred_locks_per_page': Integer(100000, None, 0, 2147483647, None),
|
||||
'max_pred_locks_per_relation': Integer(100000, None, -2147483648, 2147483647, None),
|
||||
'max_pred_locks_per_transaction': Integer(90300, None, 10, 2147483647, None),
|
||||
'max_prepared_transactions': (
|
||||
Integer(90300, 90600, 0, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'max_replication_slots': (
|
||||
Integer(90400, 90600, 0, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'max_slot_wal_keep_size': Integer(130000, None, -1, 2147483647, 'MB'),
|
||||
'max_stack_depth': Integer(90300, None, 100, 2147483647, 'kB'),
|
||||
'max_standby_archive_delay': Integer(90300, None, -1, 2147483647, 'ms'),
|
||||
'max_standby_streaming_delay': Integer(90300, None, -1, 2147483647, 'ms'),
|
||||
'max_sync_workers_per_subscription': Integer(100000, None, 0, 262143, None),
|
||||
'max_wal_senders': (
|
||||
Integer(90300, 90600, 0, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'max_wal_size': (
|
||||
Integer(90500, 100000, 2, 2147483647, '16MB'),
|
||||
Integer(100000, None, 2, 2147483647, 'MB')
|
||||
),
|
||||
'max_worker_processes': (
|
||||
Integer(90400, 90600, 1, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'min_dynamic_shared_memory': Integer(140000, None, 0, 2147483647, 'MB'),
|
||||
'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
|
||||
'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
|
||||
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
|
||||
'min_wal_size': (
|
||||
Integer(90500, 100000, 2, 2147483647, '16MB'),
|
||||
Integer(100000, None, 2, 2147483647, 'MB')
|
||||
),
|
||||
'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'),
|
||||
'operator_precedence_warning': Bool(90500, 140000),
|
||||
'parallel_leader_participation': Bool(110000, None),
|
||||
'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None),
|
||||
'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None),
|
||||
'password_encryption': (
|
||||
Bool(90300, 100000),
|
||||
Enum(100000, None, ('md5', 'scram-sha-256'))
|
||||
),
|
||||
'plan_cache_mode': Enum(120000, None, ('auto', 'force_generic_plan', 'force_custom_plan')),
|
||||
'port': Integer(90300, None, 1, 65535, None),
|
||||
'post_auth_delay': Integer(90300, None, 0, 2147, 's'),
|
||||
'pre_auth_delay': Integer(90300, None, 0, 60, 's'),
|
||||
'quote_all_identifiers': Bool(90300, None),
|
||||
'random_page_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'recovery_init_sync_method': Enum(140000, None, ('fsync', 'syncfs')),
|
||||
'recovery_prefetch': EnumBool(150000, None, ('try',)),
|
||||
'recursive_worktable_factor': Real(150000, None, 0.001, 1e+06, None),
|
||||
'remove_temp_files_after_crash': Bool(140000, None),
|
||||
'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None),
|
||||
'restart_after_crash': Bool(90300, None),
|
||||
'row_security': Bool(90500, None),
|
||||
'search_path': String(90300, None),
|
||||
'seq_page_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'session_preload_libraries': String(90400, None),
|
||||
'session_replication_role': Enum(90300, None, ('origin', 'replica', 'local')),
|
||||
'shared_buffers': Integer(90300, None, 16, 1073741823, '8kB'),
|
||||
'shared_memory_type': Enum(120000, None, ('sysv', 'mmap')),
|
||||
'shared_preload_libraries': String(90300, None),
|
||||
'sql_inheritance': Bool(90300, 100000),
|
||||
'ssl': Bool(90300, None),
|
||||
'ssl_ca_file': String(90300, None),
|
||||
'ssl_cert_file': String(90300, None),
|
||||
'ssl_ciphers': String(90300, None),
|
||||
'ssl_crl_dir': String(140000, None),
|
||||
'ssl_crl_file': String(90300, None),
|
||||
'ssl_dh_params_file': String(100000, None),
|
||||
'ssl_ecdh_curve': String(90400, None),
|
||||
'ssl_key_file': String(90300, None),
|
||||
'ssl_max_protocol_version': Enum(120000, None, ('', 'tlsv1', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3')),
|
||||
'ssl_min_protocol_version': Enum(120000, None, ('tlsv1', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3')),
|
||||
'ssl_passphrase_command': String(110000, None),
|
||||
'ssl_passphrase_command_supports_reload': Bool(110000, None),
|
||||
'ssl_prefer_server_ciphers': Bool(90400, None),
|
||||
'ssl_renegotiation_limit': Integer(90300, 90500, 0, 2147483647, 'kB'),
|
||||
'standard_conforming_strings': Bool(90300, None),
|
||||
'statement_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
|
||||
'stats_fetch_consistency': Enum(150000, None, ('none', 'cache', 'snapshot')),
|
||||
'stats_temp_directory': String(90300, 150000),
|
||||
'superuser_reserved_connections': (
|
||||
Integer(90300, 90600, 0, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'synchronize_seqscans': Bool(90300, None),
|
||||
'synchronous_commit': (
|
||||
EnumBool(90300, 90600, ('local', 'remote_write')),
|
||||
EnumBool(90600, None, ('local', 'remote_write', 'remote_apply'))
|
||||
),
|
||||
'synchronous_standby_names': String(90300, None),
|
||||
'syslog_facility': Enum(90300, None, ('local0', 'local1', 'local2', 'local3',
|
||||
'local4', 'local5', 'local6', 'local7')),
|
||||
'syslog_ident': String(90300, None),
|
||||
'syslog_sequence_numbers': Bool(90600, None),
|
||||
'syslog_split_messages': Bool(90600, None),
|
||||
'tcp_keepalives_count': Integer(90300, None, 0, 2147483647, None),
|
||||
'tcp_keepalives_idle': Integer(90300, None, 0, 2147483647, 's'),
|
||||
'tcp_keepalives_interval': Integer(90300, None, 0, 2147483647, 's'),
|
||||
'tcp_user_timeout': Integer(120000, None, 0, 2147483647, 'ms'),
|
||||
'temp_buffers': Integer(90300, None, 100, 1073741823, '8kB'),
|
||||
'temp_file_limit': Integer(90300, None, -1, 2147483647, 'kB'),
|
||||
'temp_tablespaces': String(90300, None),
|
||||
'TimeZone': String(90300, None),
|
||||
'timezone_abbreviations': String(90300, None),
|
||||
'trace_notify': Bool(90300, None),
|
||||
'trace_recovery_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
|
||||
'debug1', 'log', 'notice', 'warning', 'error')),
|
||||
'trace_sort': Bool(90300, None),
|
||||
'track_activities': Bool(90300, None),
|
||||
'track_activity_query_size': (
|
||||
Integer(90300, 110000, 100, 102400, None),
|
||||
Integer(110000, 130000, 100, 102400, 'B'),
|
||||
Integer(130000, None, 100, 1048576, 'B')
|
||||
),
|
||||
'track_commit_timestamp': Bool(90500, None),
|
||||
'track_counts': Bool(90300, None),
|
||||
'track_functions': Enum(90300, None, ('none', 'pl', 'all')),
|
||||
'track_io_timing': Bool(90300, None),
|
||||
'track_wal_io_timing': Bool(140000, None),
|
||||
'transaction_deferrable': Bool(90300, None),
|
||||
'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
|
||||
'read committed', 'read uncommitted')),
|
||||
'transaction_read_only': Bool(90300, None),
|
||||
'transform_null_equals': Bool(90300, None),
|
||||
'unix_socket_directories': String(90300, None),
|
||||
'unix_socket_group': String(90300, None),
|
||||
'unix_socket_permissions': Integer(90300, None, 0, 511, None),
|
||||
'update_process_title': Bool(90300, None),
|
||||
'vacuum_cleanup_index_scale_factor': Real(110000, 140000, 0, 1e+10, None),
|
||||
'vacuum_cost_delay': (
|
||||
Integer(90300, 120000, 0, 100, 'ms'),
|
||||
Real(120000, None, 0, 100, 'ms')
|
||||
),
|
||||
'vacuum_cost_limit': Integer(90300, None, 1, 10000, None),
|
||||
'vacuum_cost_page_dirty': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_cost_page_hit': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_cost_page_miss': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_defer_cleanup_age': Integer(90300, None, 0, 1000000, None),
|
||||
'vacuum_failsafe_age': Integer(140000, None, 0, 2100000000, None),
|
||||
'vacuum_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
|
||||
'vacuum_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
|
||||
'vacuum_multixact_failsafe_age': Integer(140000, None, 0, 2100000000, None),
|
||||
'vacuum_multixact_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
|
||||
'vacuum_multixact_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
|
||||
'wal_buffers': Integer(90300, None, -1, 262143, '8kB'),
|
||||
'wal_compression': (
|
||||
Bool(90500, 150000),
|
||||
EnumBool(150000, None, ('pglz', 'lz4', 'zstd'))
|
||||
),
|
||||
'wal_consistency_checking': String(100000, None),
|
||||
'wal_decode_buffer_size': Integer(150000, None, 65536, 1073741823, 'B'),
|
||||
'wal_init_zero': Bool(120000, None),
|
||||
'wal_keep_segments': Integer(90300, 130000, 0, 2147483647, None),
|
||||
'wal_keep_size': Integer(130000, None, 0, 2147483647, 'MB'),
|
||||
'wal_level': (
|
||||
Enum(90300, 90400, ('minimal', 'archive', 'hot_standby')),
|
||||
Enum(90400, 90600, ('minimal', 'archive', 'hot_standby', 'logical')),
|
||||
Enum(90600, None, ('minimal', 'replica', 'logical'))
|
||||
),
|
||||
'wal_log_hints': Bool(90400, None),
|
||||
'wal_receiver_create_temp_slot': Bool(130000, None),
|
||||
'wal_receiver_status_interval': Integer(90300, None, 0, 2147483, 's'),
|
||||
'wal_receiver_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
|
||||
'wal_recycle': Bool(120000, None),
|
||||
'wal_retrieve_retry_interval': Integer(90500, None, 1, 2147483647, 'ms'),
|
||||
'wal_sender_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
|
||||
'wal_skip_threshold': Integer(130000, None, 0, 2147483647, 'kB'),
|
||||
'wal_sync_method': Enum(90300, None, ('fsync', 'fdatasync', 'open_sync', 'open_datasync')),
|
||||
'wal_writer_delay': Integer(90300, None, 1, 10000, 'ms'),
|
||||
'wal_writer_flush_after': Integer(90600, None, 0, 2147483647, '8kB'),
|
||||
'work_mem': Integer(90300, None, 64, 2147483647, 'kB'),
|
||||
'xmlbinary': Enum(90300, None, ('base64', 'hex')),
|
||||
'xmloption': Enum(90300, None, ('content', 'document')),
|
||||
'zero_damaged_pages': Bool(90300, None)
|
||||
})
|
||||
# key - parameter name
|
||||
# value - variable length tuple of `_Transformable` objects. Each object in the tuple represents a different
|
||||
# validation of the GUC across postgres versions. If a GUC validation has never changed over time, then it will
|
||||
# have a single object in the tuple. For example, `password_encryption` used to be a boolean GUC up to Postgres
|
||||
# 10, at which point it started being an enum. In that case the value of `password_encryption` would be a tuple
|
||||
# of 2 `_Transformable` objects (`Bool` and `Enum`, respectively), each one reprensenting a different
|
||||
# validation rule.
|
||||
parameters = CaseInsensitiveDict()
|
||||
recovery_parameters = CaseInsensitiveDict()
|
||||
|
||||
|
||||
recovery_parameters = CaseInsensitiveDict({
|
||||
'archive_cleanup_command': String(90300, None),
|
||||
'pause_at_recovery_target': Bool(90300, 90500),
|
||||
'primary_conninfo': String(90300, None),
|
||||
'primary_slot_name': String(90400, None),
|
||||
'promote_trigger_file': String(120000, None),
|
||||
'recovery_end_command': String(90300, None),
|
||||
'recovery_min_apply_delay': Integer(90400, None, 0, 2147483647, 'ms'),
|
||||
'recovery_target': Enum(90400, None, ('immediate', '')),
|
||||
'recovery_target_action': Enum(90500, None, ('pause', 'promote', 'shutdown')),
|
||||
'recovery_target_inclusive': Bool(90300, None),
|
||||
'recovery_target_lsn': String(100000, None),
|
||||
'recovery_target_name': String(90400, None),
|
||||
'recovery_target_time': String(90300, None),
|
||||
'recovery_target_timeline': String(90300, None),
|
||||
'recovery_target_xid': String(90300, None),
|
||||
'restore_command': String(90300, None),
|
||||
'standby_mode': Bool(90300, 120000),
|
||||
'trigger_file': String(90300, 120000)
|
||||
})
|
||||
class ValidatorFactoryNoType(PatroniException):
|
||||
"""Raised when a validator spec misses a type."""
|
||||
|
||||
|
||||
def _transform_parameter_value(validators, version, name, value):
|
||||
validators = validators.get(name)
|
||||
if validators:
|
||||
for validator in (validators if isinstance(validators[0], tuple) else [validators]):
|
||||
class ValidatorFactoryInvalidType(PatroniException):
|
||||
"""Raised when a validator spec contains an invalid type."""
|
||||
|
||||
|
||||
class ValidatorFactoryInvalidSpec(PatroniException):
|
||||
"""Raised when a validator spec contains an invalid set of attributes."""
|
||||
|
||||
|
||||
class ValidatorFactory:
|
||||
"""Factory class used to build Patroni validator objects based on the given specs."""
|
||||
|
||||
TYPES: Dict[str, Type[_Transformable]] = {cls.__name__: cls for cls in _Transformable.get_subclasses()}
|
||||
|
||||
def __new__(cls, validator: Dict[str, Any]) -> _Transformable:
|
||||
"""Parse a given Postgres GUC *validator* into the corresponding Patroni validator object.
|
||||
|
||||
:param validator: a validator spec for a given parameter. It usually comes from a parsed YAML file.
|
||||
|
||||
:returns: the Patroni validator object that corresponds to the specification found in *validator*.
|
||||
|
||||
:raises:
|
||||
:class:`ValidatorFactoryNoType`: if *validator* contains no ``type`` key.
|
||||
:class:`ValidatorFactoryInvalidType`: if ``type`` key from *validator* contains an invalid value.
|
||||
:class:`ValidatorFactoryInvalidSpec`: if *validator* contains an invalid set of attributes for the given
|
||||
``type``.
|
||||
|
||||
:Example:
|
||||
|
||||
If a given validator was defined as follows in the YAML file:
|
||||
|
||||
```yaml
|
||||
- type: String
|
||||
version_from: 90300
|
||||
version_till: null
|
||||
```
|
||||
|
||||
Then this method would receive *validator* as:
|
||||
|
||||
```python
|
||||
{
|
||||
'type': 'String',
|
||||
'version_from': 90300,
|
||||
'version_till': None
|
||||
}
|
||||
```
|
||||
|
||||
And this method would return a :class:`String`:
|
||||
|
||||
```python
|
||||
String(90300, None)
|
||||
```
|
||||
"""
|
||||
validator = deepcopy(validator)
|
||||
try:
|
||||
type_ = validator.pop('type')
|
||||
except KeyError as exc:
|
||||
raise ValidatorFactoryNoType('Validator contains no type.') from exc
|
||||
|
||||
if type_ not in cls.TYPES:
|
||||
raise ValidatorFactoryInvalidType(f'Unexpected validator type: `{type_}`.')
|
||||
|
||||
for key, value in validator.items():
|
||||
# :func:`_transform_parameter_value` expects :class:`tuple` instead of :class:`list`
|
||||
if isinstance(value, list):
|
||||
tmp_value: List[Any] = value
|
||||
validator[key] = tuple(tmp_value)
|
||||
|
||||
try:
|
||||
return cls.TYPES[type_](**validator)
|
||||
except Exception as exc:
|
||||
raise ValidatorFactoryInvalidSpec(
|
||||
f'Failed to parse `{type_}` validator (`{validator}`): `{str(exc)}`.') from exc
|
||||
|
||||
|
||||
def _get_postgres_guc_validators(config: Dict[str, Any], parameter: str) -> Tuple[_Transformable, ...]:
|
||||
"""Get all validators of *parameter* from *config*.
|
||||
|
||||
Loop over all validators specs of *parameter* and return them parsed as Patroni validators.
|
||||
|
||||
:param config: Python object corresponding to an YAML file, with values of either ``parameters`` or
|
||||
``recovery_parameters`` key.
|
||||
:param parameter: name of the parameter found under *config* which validators should be parsed and returned.
|
||||
|
||||
:rtype: yields any exception that is faced while parsing a validator spec into a Patroni validator object.
|
||||
"""
|
||||
validators: List[_Transformable] = []
|
||||
for validator_spec in config.get(parameter, []):
|
||||
try:
|
||||
validator = ValidatorFactory(validator_spec)
|
||||
validators.append(validator)
|
||||
except (ValidatorFactoryNoType, ValidatorFactoryInvalidType, ValidatorFactoryInvalidSpec) as exc:
|
||||
logger.warning('Faced an issue while parsing a validator for parameter `%s`: `%r`', parameter, exc)
|
||||
|
||||
return tuple(validators)
|
||||
|
||||
|
||||
class InvalidGucValidatorsFile(PatroniException):
|
||||
"""Raised when reading or parsing of a YAML file faces an issue."""
|
||||
|
||||
|
||||
def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
|
||||
"""Read an YAML file and return the corresponding Python object.
|
||||
|
||||
:param file: path to the file to be read. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
|
||||
|
||||
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
|
||||
return ``None``.
|
||||
|
||||
:raises:
|
||||
:class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
|
||||
"""
|
||||
try:
|
||||
with open(file, encoding='UTF-8') as stream:
|
||||
return yaml.safe_load(stream)
|
||||
except Exception as exc:
|
||||
raise InvalidGucValidatorsFile(
|
||||
f'Unexpected issue while reading parameters file `{file}`: `{str(exc)}`.') from exc
|
||||
|
||||
|
||||
def _load_postgres_gucs_validators() -> None:
|
||||
"""Load all Postgres GUC validators from YAML files.
|
||||
|
||||
Recursively walk through ``available_parameters`` directory and load validators of each found YAML file into
|
||||
``parameters`` and/or ``recovery_parameters`` variables.
|
||||
|
||||
Walk through directories in top-down fashion and for each of them:
|
||||
* Sort files by name;
|
||||
* Load validators from YAML files that were found.
|
||||
|
||||
Any problem faced while reading or parsing files will be logged as a ``WARNING`` by the child function, and the
|
||||
corresponding file or validator will be ignored.
|
||||
|
||||
By default, Patroni only ships the file ``0_postgres.yml``, which contains Community Postgres GUCs validators, but
|
||||
that behavior can be extended. For example: if a vendor wants to add GUC validators to Patroni for covering a custom
|
||||
Postgres build, then they can create their custom YAML files under ``available_parameters`` directory.
|
||||
|
||||
Each YAML file may contain either or both of these root attributes, here called sections:
|
||||
* ``parameters``: general GUCs that would be written to ``postgresql.conf``;
|
||||
* ``recovery_parameters``: recovery related GUCs that would be written to ``recovery.conf`` (Patroni later
|
||||
writes them to ``postgresql.conf`` if running PG 12 and above).
|
||||
|
||||
Then, each of these sections, if specified, may contain one or more attributes with the following structure:
|
||||
|
||||
* key: the name of a GUC;
|
||||
* value: a list of validators. Each item in the list must contain a ``type`` attribute, which must be one among:
|
||||
|
||||
* ``Bool``; or
|
||||
* ``Integer``; or
|
||||
* ``Real``; or
|
||||
* ``Enum``; or
|
||||
* ``EnumBool``; or
|
||||
* ``String``.
|
||||
|
||||
Besides the ``type`` attribute, it should also contain all the required attributes as per the corresponding
|
||||
class in this module.
|
||||
|
||||
.. seealso::
|
||||
|
||||
* :class:`Bool`;
|
||||
* :class:`Integer`;
|
||||
* :class:`Real`;
|
||||
* :class:`Enum`;
|
||||
* :class:`EnumBool`;
|
||||
* :class:`String`.
|
||||
|
||||
:Example:
|
||||
|
||||
This is a sample content for an YAML file based on Postgres GUCs, showing each of the supported types and
|
||||
sections:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
parameters:
|
||||
archive_command:
|
||||
- type: String
|
||||
version_from: 90300
|
||||
version_till: null
|
||||
archive_mode:
|
||||
- type: Bool
|
||||
version_from: 90300
|
||||
version_till: 90500
|
||||
- type: EnumBool
|
||||
version_from: 90500
|
||||
version_till: null
|
||||
possible_values:
|
||||
- always
|
||||
archive_timeout:
|
||||
- type: Integer
|
||||
version_from: 90300
|
||||
version_till: null
|
||||
min_val: 0
|
||||
max_val: 1073741823
|
||||
unit: s
|
||||
autovacuum_vacuum_cost_delay:
|
||||
- type: Integer
|
||||
version_from: 90300
|
||||
version_till: 120000
|
||||
min_val: -1
|
||||
max_val: 100
|
||||
unit: ms
|
||||
- type: Real
|
||||
version_from: 120000
|
||||
version_till: null
|
||||
min_val: -1
|
||||
max_val: 100
|
||||
unit: ms
|
||||
client_min_messages:
|
||||
- type: Enum
|
||||
version_from: 90300
|
||||
version_till: null
|
||||
possible_values:
|
||||
- debug5
|
||||
- debug4
|
||||
- debug3
|
||||
- debug2
|
||||
- debug1
|
||||
- log
|
||||
- notice
|
||||
- warning
|
||||
- error
|
||||
recovery_parameters:
|
||||
archive_cleanup_command:
|
||||
- type: String
|
||||
version_from: 90300
|
||||
version_till: null
|
||||
|
||||
"""
|
||||
conf_dir = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
'available_parameters',
|
||||
)
|
||||
yaml_files: List[str] = []
|
||||
|
||||
for root, _, files in os.walk(conf_dir):
|
||||
for file in sorted(files):
|
||||
full_path = os.path.join(root, file)
|
||||
if file.lower().endswith(('.yml', '.yaml')):
|
||||
yaml_files.append(full_path)
|
||||
else:
|
||||
logger.info('Ignored a non-YAML file found under `available_parameters` directory: `%s`.', full_path)
|
||||
|
||||
for file in yaml_files:
|
||||
try:
|
||||
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
|
||||
except InvalidGucValidatorsFile as exc:
|
||||
logger.warning(str(exc))
|
||||
continue
|
||||
|
||||
logger.debug(f'Parsing validators from file `{file}`.')
|
||||
|
||||
mapping = {
|
||||
'parameters': parameters,
|
||||
'recovery_parameters': recovery_parameters,
|
||||
}
|
||||
|
||||
for section in ['parameters', 'recovery_parameters']:
|
||||
section_var = mapping[section]
|
||||
|
||||
config_section = config.get(section, {})
|
||||
for parameter in config_section.keys():
|
||||
section_var[parameter] = _get_postgres_guc_validators(config_section, parameter)
|
||||
|
||||
|
||||
_load_postgres_gucs_validators()
|
||||
|
||||
|
||||
def _transform_parameter_value(validators: MutableMapping[str, Tuple[_Transformable, ...]],
|
||||
version: int, name: str, value: Any,
|
||||
available_gucs: CaseInsensitiveSet) -> Optional[Any]:
|
||||
"""Validate *value* of GUC *name* for Postgres *version* using defined *validators* and *available_gucs*.
|
||||
|
||||
:param validators: a dictionary of all GUCs across all Postgres versions. Each key is the name of a Postgres GUC,
|
||||
and the corresponding value is a variable length tuple of :class:`_Transformable`. Each item is a validation
|
||||
rule for the GUC for a given range of Postgres versions. Should either contain recovery GUCs or general GUCs,
|
||||
not both.
|
||||
:param version: Postgres version to validate the GUC against.
|
||||
:param name: name of the Postgres GUC.
|
||||
:param value: value of the Postgres GUC.
|
||||
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
|
||||
GUC. Used for a couple purposes:
|
||||
|
||||
* Disallow writing GUCs to ``postgresql.conf`` (or ``recovery.conf``) that does not exist in Postgres *version*;
|
||||
* Avoid ignoring GUC *name* if it does not have a validator in *validators*, but is a valid GUC in Postgres
|
||||
*version*.
|
||||
|
||||
:returns: the return value may be one among:
|
||||
|
||||
* *value* transformed to the expected format for GUC *name* in Postgres *version*, if *name* is present
|
||||
in *available_gucs* and has a validator in *validators* for the corresponding Postgres *version*; or
|
||||
* The own *value* if *name* is present in *available_gucs* but not in *validators*; or
|
||||
* ``None`` if *name* is not present in *available_gucs*.
|
||||
"""
|
||||
if name in available_gucs:
|
||||
for validator in validators.get(name, ()) or ():
|
||||
if version >= validator.version_from and\
|
||||
(validator.version_till is None or version < validator.version_till):
|
||||
return validator.transform(name, value)
|
||||
# Ideally we should have a validator in *validators*. However, if none is available, we will not discard a
|
||||
# setting that exists in Postgres *version*, but rather allow the value with no validation.
|
||||
return value
|
||||
logger.warning('Removing unexpected parameter=%s value=%s from the config', name, value)
|
||||
|
||||
|
||||
def transform_postgresql_parameter_value(version, name, value):
|
||||
if '.' in name:
|
||||
def transform_postgresql_parameter_value(version: int, name: str, value: Any,
|
||||
available_gucs: CaseInsensitiveSet) -> Optional[Any]:
|
||||
"""Validate *value* of GUC *name* for Postgres *version* using ``parameters`` and *available_gucs*.
|
||||
|
||||
:param version: Postgres version to validate the GUC against.
|
||||
:param name: name of the Postgres GUC.
|
||||
:param value: value of the Postgres GUC.
|
||||
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
|
||||
GUC. Used for a couple purposes:
|
||||
|
||||
* Disallow writing GUCs to ``postgresql.conf`` that does not exist in Postgres *version*;
|
||||
* Avoid ignoring GUC *name* if it does not have a validator in ``parameters``, but is a valid GUC in
|
||||
Postgres *version*.
|
||||
|
||||
:returns: The return value may be one among:
|
||||
|
||||
* The original *value* if *name* seems to be an extension GUC (contains a period '.'); or
|
||||
* ``None`` if **name** is a recovery GUC; or
|
||||
* *value* transformed to the expected format for GUC *name* in Postgres *version* using validators defined in
|
||||
``parameters``. Can also return ``None``. See :func:`_transform_parameter_value`.
|
||||
"""
|
||||
if '.' in name and name not in parameters:
|
||||
# likely an extension GUC, so just return as it is. Otherwise, if `name` is in `parameters`, it's likely a
|
||||
# namespaced GUC from a custom Postgres build, so we treat that over the usual validation means.
|
||||
return value
|
||||
if name in recovery_parameters:
|
||||
return None
|
||||
return _transform_parameter_value(parameters, version, name, value)
|
||||
return _transform_parameter_value(parameters, version, name, value, available_gucs)
|
||||
|
||||
|
||||
def transform_recovery_parameter_value(version, name, value):
|
||||
return _transform_parameter_value(recovery_parameters, version, name, value)
|
||||
def transform_recovery_parameter_value(version: int, name: str, value: Any,
|
||||
available_gucs: CaseInsensitiveSet) -> Optional[Any]:
|
||||
"""Validate *value* of GUC *name* for Postgres *version* using ``recovery_parameters`` and *available_gucs*.
|
||||
|
||||
:param version: Postgres version to validate the recovery GUC against.
|
||||
:param name: name of the Postgres recovery GUC.
|
||||
:param value: value of the Postgres recovery GUC.
|
||||
:param available_gucs: a set of all GUCs available in Postgres *version*. Each item is the name of a Postgres
|
||||
GUC. Used for a couple purposes:
|
||||
|
||||
* Disallow writing GUCs to ``recovery.conf`` (or ``postgresql.conf`` depending on *version*), that does not
|
||||
exist in Postgres *version*;
|
||||
* Avoid ignoring recovery GUC *name* if it does not have a validator in ``recovery_parameters``, but is a
|
||||
valid GUC in Postgres *version*.
|
||||
|
||||
:returns: *value* transformed to the expected format for recovery GUC *name* in Postgres *version* using validators
|
||||
defined in ``recovery_parameters``. It can also return ``None``. See :func:`_transform_parameter_value`.
|
||||
"""
|
||||
# Recovery settings are not present in ``postgres --describe-config`` output of Postgres <= 11. In that case we
|
||||
# just pass down the list of settings defined in Patroni validators so :func:`_transform_parameter_value` will not
|
||||
# discard the recovery GUCs when running Postgres <= 11.
|
||||
# NOTE: At the moment this change was done Postgres 11 was almost EOL, and had been likely extensively used with
|
||||
# Patroni, so we should be able to rely solely on Patroni validators as the source of truth.
|
||||
return _transform_parameter_value(
|
||||
recovery_parameters, version, name, value,
|
||||
available_gucs if version >= 120000 else CaseInsensitiveSet(recovery_parameters.keys()))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user