mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a9befc1a2 | ||
|
|
8f22fd255e | ||
|
|
6938c21ff7 | ||
|
|
32934b205f | ||
|
|
1ba9e68b4b | ||
|
|
deb9cc6b73 | ||
|
|
a3c772dfc9 | ||
|
|
9977850b56 | ||
|
|
7543e64000 | ||
|
|
5ed4d33f7d | ||
|
|
c6943dc415 | ||
|
|
36011e936a | ||
|
|
a316105412 | ||
|
|
92c4f9fbb5 | ||
|
|
1c5d9f5653 | ||
|
|
66cf21767d | ||
|
|
b573bd4c9d | ||
|
|
33600976b1 | ||
|
|
e9ba775959 | ||
|
|
cf427e8b0b | ||
|
|
7531d41587 | ||
|
|
5dbfc9401b | ||
|
|
ce79152088 | ||
|
|
6920b3af0e | ||
|
|
0d87270897 | ||
|
|
1a31ea6e20 | ||
|
|
8de904e556 | ||
|
|
c97ad83396 | ||
|
|
0bb12473fb | ||
|
|
302757b71a | ||
|
|
3d932e1e73 | ||
|
|
38aef484e8 | ||
|
|
34b2a77294 | ||
|
|
6caa2fa99c | ||
|
|
b4eab48971 | ||
|
|
2bc25a32e4 | ||
|
|
7db7dfd3c5 | ||
|
|
3938bb9a16 | ||
|
|
26ae38960a | ||
|
|
836e527e6d | ||
|
|
e73f2044c8 | ||
|
|
39f5de2e77 | ||
|
|
46e20edbc2 | ||
|
|
578dc39291 | ||
|
|
9d1609e0eb | ||
|
|
fb0fcc859a | ||
|
|
a903438a5a | ||
|
|
19f75b407e | ||
|
|
3f00b7a6c7 | ||
|
|
4ce0f99cfb | ||
|
|
efba02f52e | ||
|
|
e1faa38e90 | ||
|
|
177101a1cc | ||
|
|
7dcb9b9840 | ||
|
|
e8a8bfe42f | ||
|
|
72be036c99 | ||
|
|
969d7ec4ab | ||
|
|
75ff8b3256 | ||
|
|
4853b3b430 | ||
|
|
ba970d8c63 | ||
|
|
ff278705d6 | ||
|
|
74c0acf36d | ||
|
|
58ee52b401 | ||
|
|
877acf2a55 | ||
|
|
8e46086335 | ||
|
|
e91e6b5484 | ||
|
|
6b685036d0 | ||
|
|
78a46b9ebc | ||
|
|
d7e172c20a | ||
|
|
87cb7481ae | ||
|
|
bfa9b0ca4b | ||
|
|
416a0f7c8b | ||
|
|
94a592d275 | ||
|
|
74a72e4f78 | ||
|
|
4c951a2937 | ||
|
|
b3ae8652c9 | ||
|
|
66f98c80e8 | ||
|
|
57ed40f66c | ||
|
|
d5d6a51e2c | ||
|
|
2f800173a5 | ||
|
|
db82a83eb4 | ||
|
|
3ecdf01b50 | ||
|
|
c9322df095 | ||
|
|
b470ade20e | ||
|
|
835d93951d | ||
|
|
8cdb0c25d9 | ||
|
|
6d65aa311a | ||
|
|
8c5ab4c07d | ||
|
|
31cf951b69 | ||
|
|
7659ccd50b | ||
|
|
a03dba04e3 | ||
|
|
93eb4edbe6 | ||
|
|
c931da1eb3 | ||
|
|
fc5a8ed01c | ||
|
|
0fa41502f1 | ||
|
|
384705ad97 | ||
|
|
56dba93c55 | ||
|
|
b458bd992a | ||
|
|
5eb431b719 | ||
|
|
ab9faf9471 | ||
|
|
cd3f52b029 | ||
|
|
4456e267eb | ||
|
|
c6339234c6 | ||
|
|
b1d442e7a4 | ||
|
|
b8b5518e8c | ||
|
|
a5796a03f1 | ||
|
|
fbbd32a537 | ||
|
|
c687838074 | ||
|
|
622d41c83c | ||
|
|
f00826d6e6 | ||
|
|
a4a6dc0299 | ||
|
|
3c07410695 | ||
|
|
7067d744c6 | ||
|
|
6b7ec49282 | ||
|
|
d4fd782038 | ||
|
|
6e1f9f7a6e | ||
|
|
a5d095e316 | ||
|
|
2a003a36bb | ||
|
|
af03c619ec | ||
|
|
14a44e14ba | ||
|
|
dc7ba3fe15 | ||
|
|
1ed207cbf0 | ||
|
|
b6c5a12017 | ||
|
|
1b7b8e60fb | ||
|
|
0a91948a49 | ||
|
|
0a6c09e252 | ||
|
|
ff31f45226 | ||
|
|
ff99d29e6d | ||
|
|
03bb9125cb | ||
|
|
634b44ee05 | ||
|
|
290d05c642 | ||
|
|
9d231aeecd | ||
|
|
48fbf64ea9 | ||
|
|
3fd7c98d2b | ||
|
|
d7454f7bcd | ||
|
|
ceb2965ab8 | ||
|
|
ae53260030 | ||
|
|
9b237b332e | ||
|
|
b09af642e6 | ||
|
|
014777b20a | ||
|
|
a8cfd46801 | ||
|
|
fd3e3ca472 | ||
|
|
e131065d74 | ||
|
|
bdd02324b4 | ||
|
|
7adfc0dbe7 | ||
|
|
f6943a859d | ||
|
|
e532f9dc38 | ||
|
|
688c85389c | ||
|
|
5c4ee30dae | ||
|
|
266cdc4810 | ||
|
|
2ac1efea54 | ||
|
|
5d8c2fb559 | ||
|
|
4e5b2ee249 | ||
|
|
3390ee9dea | ||
|
|
71ccf91e36 | ||
|
|
8acefefc42 | ||
|
|
dd548c4964 | ||
|
|
bcfd8438a5 | ||
|
|
5c3e1a693e | ||
|
|
206ee91b07 | ||
|
|
c1ee99d81d | ||
|
|
f0719d148c | ||
|
|
efdedc7049 | ||
|
|
bbddca6a76 | ||
|
|
a4e0a2220d | ||
|
|
0e6a2ff3a9 | ||
|
|
6976939f09 | ||
|
|
ef5f320602 | ||
|
|
47cadc9f63 | ||
|
|
5a77cbb087 | ||
|
|
92f4aa2ef9 | ||
|
|
7c3ce78231 | ||
|
|
76e19ecfe2 | ||
|
|
9afaf6eb51 | ||
|
|
36e3dfbe41 | ||
|
|
bb804074f7 | ||
|
|
ed9d4750f9 | ||
|
|
193c73f6b8 | ||
|
|
91327f943c | ||
|
|
ac6f6ae1c2 | ||
|
|
70b0991e6a | ||
|
|
5dab735534 | ||
|
|
ecf158bce3 | ||
|
|
1870dcd8f9 | ||
|
|
7370f70f13 | ||
|
|
1b96ae9c0a | ||
|
|
3ffd598a1c | ||
|
|
552e8643d9 | ||
|
|
269b04be5d | ||
|
|
8adddb3467 | ||
|
|
d72f7cb259 | ||
|
|
c3dce46830 |
@@ -9,6 +9,11 @@ import zipfile
|
||||
|
||||
|
||||
def install_requirements(what):
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
s = subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'wheel', 'setuptools'])
|
||||
if s != 0:
|
||||
return s
|
||||
|
||||
old_path = sys.path[:]
|
||||
w = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe())))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(w)))
|
||||
@@ -16,23 +21,25 @@ def install_requirements(what):
|
||||
from setup import EXTRAS_REQUIRE, read
|
||||
finally:
|
||||
sys.path = old_path
|
||||
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
|
||||
requirements = ['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, 8, 0) and\
|
||||
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
|
||||
for r in read('requirements.txt').split('\n'):
|
||||
r = r.strip()
|
||||
if r != '':
|
||||
extras = {e for e, v in EXTRAS_REQUIRE.items() if v and any(r.startswith(x) for x in v)}
|
||||
if not extras or what == 'all' or what in extras:
|
||||
requirements.append(r)
|
||||
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary==2.9.9'
|
||||
if sys.platform == 'darwin' else 'psycopg2-binary']
|
||||
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'wheel'])
|
||||
r = subprocess.call([sys.executable, '-m', 'pip', 'install'] + requirements)
|
||||
s = subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'setuptools'])
|
||||
return s | r
|
||||
from pip._vendor.distlib.markers import evaluator, DEFAULT_CONTEXT
|
||||
from pip._vendor.distlib.util import parse_requirement
|
||||
|
||||
for r in read('requirements.txt').split('\n'):
|
||||
r = parse_requirement(r)
|
||||
if not r or r.marker and not evaluator.evaluate(r.marker, DEFAULT_CONTEXT):
|
||||
continue
|
||||
extras = {e for e, v in EXTRAS_REQUIRE.items() if v and any(r.requirement.startswith(x) for x in v)}
|
||||
if not extras or what == 'all' or what in extras:
|
||||
requirements.append(r.requirement)
|
||||
|
||||
return subprocess.call([sys.executable, '-m', 'pip', 'install'] + requirements)
|
||||
|
||||
|
||||
def install_packages(what):
|
||||
@@ -45,8 +52,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)]
|
||||
if 15 <= float(ver) < 18:
|
||||
packages += ['postgresql-{0}-citus-13.0'.format(ver)]
|
||||
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
|
||||
|
||||
@@ -110,7 +117,7 @@ def install_etcd():
|
||||
|
||||
|
||||
def install_postgres():
|
||||
version = os.environ.get('PGVERSION', '15.1-1')
|
||||
version = os.environ.get('PGVERSION', '16.1-1')
|
||||
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
|
||||
if platform == 'osx':
|
||||
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
|
||||
|
||||
@@ -1 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '17', 'exhibitor': '12', 'raft': '14', 'kubernetes': '15'}
|
||||
|
||||
@@ -10,13 +10,15 @@ jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish Patroni distributions to PyPI and TestPyPI
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
@@ -32,13 +34,10 @@ jobs:
|
||||
|
||||
- name: Publish distribution to Test PyPI
|
||||
if: github.event_name == 'push'
|
||||
uses: pypa/gh-action-pypi-publish@v1.5.1
|
||||
uses: pypa/gh-action-pypi-publish@v1.9.0
|
||||
with:
|
||||
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
repository_url: https://test.pypi.org/legacy/
|
||||
|
||||
- name: Publish distribution to PyPI
|
||||
if: github.event_name == 'release'
|
||||
uses: pypa/gh-action-pypi-publish@v1.5.1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
uses: pypa/gh-action-pypi-publish@v1.9.0
|
||||
|
||||
@@ -27,11 +27,11 @@ def main():
|
||||
|
||||
version = versions.get(what)
|
||||
path = '/usr/lib/postgresql/{0}/bin:.'.format(version)
|
||||
unbuffer = ['timeout', '900', 'unbuffer']
|
||||
unbuffer = ['timeout', '1200', 'unbuffer']
|
||||
else:
|
||||
if sys.platform == 'darwin':
|
||||
version = os.environ.get('PGVERSION', '15.1-1')
|
||||
path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
|
||||
version = os.environ.get('PGVERSION', '16.1-1')
|
||||
path = '/opt/homebrew/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
|
||||
unbuffer = ['unbuffer']
|
||||
else:
|
||||
path = os.path.abspath(os.path.join('pgsql', 'bin'))
|
||||
|
||||
@@ -13,26 +13,29 @@ env:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
runs-on: ${{ fromJson('{"ubuntu":"ubuntu-22.04","windows":"windows-latest","macos":"macos-latest"}')[matrix.os] }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu, windows, macos]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.7
|
||||
if: matrix.os != 'macos'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
if: matrix.os != 'macos'
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
if: matrix.os != 'macos'
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install dependencies
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
@@ -50,7 +53,7 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
@@ -59,7 +62,7 @@ jobs:
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install dependencies
|
||||
@@ -67,6 +70,27 @@ jobs:
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
if: matrix.os != 'macos'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
if: matrix.os != 'macos'
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
if: matrix.os != 'macos'
|
||||
|
||||
- name: Combine coverage
|
||||
run: python .github/workflows/run_tests.py combine
|
||||
|
||||
@@ -81,16 +105,16 @@ jobs:
|
||||
run: python -m coveralls --service=github
|
||||
|
||||
behave:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
runs-on: ${{ fromJson('{"ubuntu":"ubuntu-22.04","windows":"windows-latest","macos":"macos-latest"}')[matrix.os] }}
|
||||
env:
|
||||
DCS: ${{ matrix.dcs }}
|
||||
ETCDVERSION: 3.4.23
|
||||
PGVERSION: 15.1-1 # for windows and macos
|
||||
PGVERSION: 16.1-1 # for windows and macos
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu]
|
||||
python-version: [3.7, '3.10']
|
||||
python-version: [3.7, 3.13]
|
||||
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
|
||||
include:
|
||||
- os: macos
|
||||
@@ -104,9 +128,9 @@ jobs:
|
||||
dcs: etcd3
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: nolar/setup-k3d-k3s@v1
|
||||
@@ -125,12 +149,12 @@ jobs:
|
||||
- name: Run behave tests
|
||||
run: python .github/workflows/run_tests.py
|
||||
- name: Upload logs if behave failed
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: behave-${{ matrix.os }}-${{ matrix.dcs }}-${{ matrix.python-version }}-logs
|
||||
path: |
|
||||
features/output/*_failed/*postgres?.*
|
||||
features/output/*_failed/*postgres-?.*
|
||||
features/output/*.log
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
@@ -145,7 +169,7 @@ jobs:
|
||||
needs: unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- run: python -m pip install coveralls
|
||||
- run: python -m coveralls --service=github --finish
|
||||
env:
|
||||
@@ -162,27 +186,44 @@ jobs:
|
||||
pyright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
python-version: 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install -r requirements.txt psycopg2-binary psycopg
|
||||
|
||||
- uses: jakebailey/pyright-action@v1
|
||||
- uses: jakebailey/pyright-action@v2
|
||||
with:
|
||||
version: 1.1.333
|
||||
version: 1.1.394
|
||||
|
||||
ydiff:
|
||||
name: Test compatibility with the latest version of ydiff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Update ydiff
|
||||
run: python -m pip install -U ydiff
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/test_ctl.py -v
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
cache: pip
|
||||
@@ -199,3 +240,20 @@ jobs:
|
||||
|
||||
- name: Generate documentation
|
||||
run: tox -m docs
|
||||
|
||||
isort:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: pip
|
||||
|
||||
- name: isort
|
||||
uses: isort/isort-action@master
|
||||
with:
|
||||
requirementsFiles: "requirements.txt requirements.dev.txt requirements.docs.txt"
|
||||
sort-paths: "patroni tests features setup.py"
|
||||
|
||||
+2
-1
@@ -27,7 +27,7 @@ lib64
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.coverage*
|
||||
.tox
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
@@ -35,6 +35,7 @@ htmlcov
|
||||
junit.xml
|
||||
features/output*
|
||||
dummy
|
||||
result.json
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||
ARG PG_MAJOR=15
|
||||
ARG PG_MAJOR=16
|
||||
ARG COMPRESS=false
|
||||
ARG PGHOME=/home/postgres
|
||||
ARG PGDATA=$PGHOME/data
|
||||
@@ -94,9 +94,9 @@ RUN set -ex \
|
||||
/usr/share/locale/??_?? \
|
||||
/usr/share/postgresql/*/man \
|
||||
/usr/share/postgresql-common/pg_wrapper \
|
||||
/usr/share/vim/vim80/doc \
|
||||
/usr/share/vim/vim80/lang \
|
||||
/usr/share/vim/vim80/tutor \
|
||||
/usr/share/vim/vim*/doc \
|
||||
/usr/share/vim/vim*/lang \
|
||||
/usr/share/vim/vim*/tutor \
|
||||
# /var/lib/dpkg/info/* \
|
||||
&& find /usr/bin -xtype l -delete \
|
||||
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
||||
@@ -125,6 +125,8 @@ RUN if [ "$COMPRESS" = "true" ]; then \
|
||||
&& /bin/busybox sh -c "(find $save_dirs -not -type d && cat /exclude /exclude && echo exclude) | sort | uniq -u | xargs /bin/busybox rm" \
|
||||
&& /bin/busybox --install -s \
|
||||
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
|
||||
else \
|
||||
/bin/busybox --install -s; \
|
||||
fi
|
||||
|
||||
FROM scratch
|
||||
@@ -143,6 +145,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
|
||||
|
||||
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
|
||||
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
|
||||
ENV ETCDCTL_API=3
|
||||
|
||||
COPY patroni /patroni/
|
||||
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
|
||||
|
||||
+8
-7
@@ -1,13 +1,13 @@
|
||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||
ARG PG_MAJOR=15
|
||||
ARG PG_MAJOR=16
|
||||
ARG COMPRESS=false
|
||||
ARG PGHOME=/home/postgres
|
||||
ARG PGDATA=$PGHOME/data
|
||||
ARG LC_ALL=C.UTF-8
|
||||
ARG LANG=C.UTF-8
|
||||
|
||||
FROM postgres:$PG_MAJOR as builder
|
||||
FROM postgres:$PG_MAJOR AS builder
|
||||
|
||||
ARG PGHOME
|
||||
ARG PGDATA
|
||||
@@ -40,7 +40,7 @@ RUN set -ex \
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
|
||||
fi \
|
||||
\
|
||||
# Cleanup all locales but en_US.UTF-8
|
||||
@@ -113,9 +113,9 @@ RUN set -ex \
|
||||
/usr/share/locale/??_?? \
|
||||
/usr/share/postgresql/*/man \
|
||||
/usr/share/postgresql-common/pg_wrapper \
|
||||
/usr/share/vim/vim80/doc \
|
||||
/usr/share/vim/vim80/lang \
|
||||
/usr/share/vim/vim80/tutor \
|
||||
/usr/share/vim/vim*/doc \
|
||||
/usr/share/vim/vim*/lang \
|
||||
/usr/share/vim/vim*/tutor \
|
||||
# /var/lib/dpkg/info/* \
|
||||
&& find /usr/bin -xtype l -delete \
|
||||
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
||||
@@ -164,6 +164,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
|
||||
|
||||
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
|
||||
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
|
||||
ENV ETCDCTL_API=3
|
||||
|
||||
COPY patroni /patroni/
|
||||
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
|
||||
@@ -179,7 +180,7 @@ RUN sed -i 's/env python/&3/' /patroni*.py \
|
||||
&& 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/^scope:/log:\n loggers:\n patroni.postgresql.mpp.citus: DEBUG\n#&/' 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 \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Compose, Zalando SE
|
||||
Copyright (c) 2025 Compose, Zalando SE, Patroni Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+18
-18
@@ -12,11 +12,11 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
|
||||
|
||||
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 16.
|
||||
Currently supported PostgreSQL versions: 9.3 to 17.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/patroni/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.
|
||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/patroni/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
@@ -27,29 +27,27 @@ Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
How Patroni Works
|
||||
=================
|
||||
|
||||
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.
|
||||
Patroni (formerly known as Zalando's Patroni) originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
|
||||
|
||||
For additional background info, see:
|
||||
|
||||
* `Elephants on Automatic: HA Clustered PostgreSQL with Helm <https://www.youtube.com/watch?v=CftcVhFMGSY>`_, talk by Josh Berkus and Oleksii Kliukin at KubeCon Berlin 2017
|
||||
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
|
||||
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
|
||||
* `Feb. 2016 Zalando Tech blog post <https://engineering.zalando.com/posts/2016/02/zalandos-patroni-a-template-for-high-availability-postgresql.html>`__
|
||||
|
||||
==================
|
||||
Development Status
|
||||
==================
|
||||
|
||||
Patroni is in active development and accepts contributions. See our `Contributing <https://github.com/zalando/patroni/blob/master/docs/CONTRIBUTING.rst>`__ section below for more details.
|
||||
Patroni is in active development and accepts contributions. See our `Contributing <https://github.com/patroni/patroni/blob/master/docs/contributing_guidelines.rst>`__ section below for more details.
|
||||
|
||||
We report new releases information `here <https://github.com/zalando/patroni/releases>`__.
|
||||
We report new releases information `here <https://github.com/patroni/patroni/releases>`__.
|
||||
|
||||
=========
|
||||
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://pgtreats.info/slack-invite>`__. 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/patroni/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
|
||||
@@ -93,7 +91,7 @@ where dependencies can be either empty, or consist of one or more of the followi
|
||||
etcd or etcd3
|
||||
`python-etcd` module in order to use Etcd as DCS
|
||||
consul
|
||||
`python-consul` module in order to use Consul as DCS
|
||||
`py-consul` module in order to use Consul as DCS
|
||||
zookeeper
|
||||
`kazoo` module in order to use Zookeeper as DCS
|
||||
exhibitor
|
||||
@@ -104,6 +102,8 @@ raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto3` in order to use AWS callbacks
|
||||
systemd
|
||||
`systemd-python` in order to use sd_notify integration
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg3
|
||||
@@ -151,19 +151,19 @@ run:
|
||||
YAML Configuration
|
||||
==================
|
||||
|
||||
Go `here <https://github.com/zalando/patroni/blob/master/docs/SETTINGS.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
||||
Go `here <https://github.com/patroni/patroni/blob/master/docs/dynamic_configuration.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/patroni/patroni/blob/master/postgres0.yml>`__.
|
||||
|
||||
=========================
|
||||
Environment Configuration
|
||||
=========================
|
||||
|
||||
Go `here <https://github.com/zalando/patroni/blob/master/docs/ENVIRONMENT.rst>`__ for comprehensive information about configuring(overriding) settings via environment variables.
|
||||
Go `here <https://github.com/patroni/patroni/blob/master/docs/ENVIRONMENT.rst>`__ for comprehensive information about configuring(overriding) settings via environment variables.
|
||||
|
||||
===================
|
||||
Replication Choices
|
||||
===================
|
||||
|
||||
Patroni uses Postgres' streaming replication, which is asynchronous by default. Patroni's asynchronous replication configuration allows for ``maximum_lag_on_failover`` settings. This setting ensures failover will not occur if a follower is more than a certain number of bytes behind the leader. This setting should be increased or decreased based on business requirements. It's also possible to use synchronous replication for better durability guarantees. See `replication modes documentation <https://github.com/zalando/patroni/blob/master/docs/replication_modes.rst>`__ for details.
|
||||
Patroni uses Postgres' streaming replication, which is asynchronous by default. Patroni's asynchronous replication configuration allows for ``maximum_lag_on_failover`` settings. This setting ensures failover will not occur if a follower is more than a certain number of bytes behind the leader. This setting should be increased or decreased based on business requirements. It's also possible to use synchronous replication for better durability guarantees. See `replication modes documentation <https://github.com/patroni/patroni/blob/master/docs/replication_modes.rst>`__ for details.
|
||||
|
||||
======================================
|
||||
Applications Should Not Use Superusers
|
||||
@@ -171,7 +171,7 @@ 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.
|
||||
|
||||
.. |Tests Status| image:: https://github.com/zalando/patroni/actions/workflows/tests.yaml/badge.svg
|
||||
:target: https://github.com/zalando/patroni/actions/workflows/tests.yaml?query=branch%3Amaster
|
||||
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
|
||||
:target: https://coveralls.io/github/zalando/patroni?branch=master
|
||||
.. |Tests Status| image:: https://github.com/patroni/patroni/actions/workflows/tests.yaml/badge.svg
|
||||
:target: https://github.com/patroni/patroni/actions/workflows/tests.yaml?query=branch%3Amaster
|
||||
.. |Coverage Status| image:: https://coveralls.io/repos/patroni/patroni/badge.svg?branch=master
|
||||
:target: https://coveralls.io/github/patroni/patroni?branch=master
|
||||
|
||||
+36
-15
@@ -9,17 +9,19 @@
|
||||
# $ docker-compose -f docker-compose-citus.yml up -d
|
||||
# You can read more about it in the:
|
||||
# https://github.com/zalando/patroni/blob/master/docker/README.md#citus-cluster
|
||||
version: "2"
|
||||
version: "3"
|
||||
|
||||
networks:
|
||||
demo:
|
||||
|
||||
services:
|
||||
etcd1: &etcd
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- 2379
|
||||
- 2380
|
||||
environment:
|
||||
ETCDCTL_API: 3
|
||||
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
|
||||
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
|
||||
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
|
||||
@@ -28,22 +30,28 @@ services:
|
||||
ETCD_UNSUPPORTED_ARCH: arm64
|
||||
container_name: demo-etcd1
|
||||
hostname: etcd1
|
||||
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
|
||||
|
||||
etcd2:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd2
|
||||
ports:
|
||||
- 2379
|
||||
- 2380
|
||||
hostname: etcd2
|
||||
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
|
||||
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
|
||||
|
||||
etcd3:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd3
|
||||
ports:
|
||||
- 2379
|
||||
- 2380
|
||||
hostname: etcd3
|
||||
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
|
||||
|
||||
haproxy:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
env_file: docker/patroni.env
|
||||
hostname: haproxy
|
||||
@@ -53,7 +61,6 @@ services:
|
||||
- "5001:5001" # Load-balancing across workers primaries
|
||||
command: haproxy
|
||||
environment: &haproxy_env
|
||||
ETCDCTL_API: 3
|
||||
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
|
||||
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
|
||||
PATRONI_SCOPE: demo
|
||||
@@ -65,8 +72,10 @@ services:
|
||||
PGSSLROOTCERT: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
|
||||
coord1:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: coord1
|
||||
container_name: demo-coord1
|
||||
@@ -76,8 +85,10 @@ services:
|
||||
PATRONI_CITUS_GROUP: 0
|
||||
|
||||
coord2:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: coord2
|
||||
container_name: demo-coord2
|
||||
@@ -86,8 +97,10 @@ services:
|
||||
PATRONI_NAME: coord2
|
||||
|
||||
coord3:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: coord3
|
||||
container_name: demo-coord3
|
||||
@@ -97,8 +110,10 @@ services:
|
||||
|
||||
|
||||
work1-1:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: work1-1
|
||||
container_name: demo-work1-1
|
||||
@@ -108,8 +123,10 @@ services:
|
||||
PATRONI_CITUS_GROUP: 1
|
||||
|
||||
work1-2:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: work1-2
|
||||
container_name: demo-work1-2
|
||||
@@ -119,8 +136,10 @@ services:
|
||||
|
||||
|
||||
work2-1:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: work2-1
|
||||
container_name: demo-work2-1
|
||||
@@ -130,8 +149,10 @@ services:
|
||||
PATRONI_CITUS_GROUP: 2
|
||||
|
||||
work2-2:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus:latest
|
||||
networks: [ demo ]
|
||||
ports:
|
||||
- "5432"
|
||||
env_file: docker/patroni.env
|
||||
hostname: work2-2
|
||||
container_name: demo-work2-2
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@
|
||||
# The cluster could be started as:
|
||||
# $ docker-compose up -d
|
||||
# You can read more about it in the:
|
||||
# https://github.com/zalando/patroni/blob/master/docker/README.md
|
||||
# https://github.com/patroni/patroni/blob/master/docker/README.md
|
||||
version: "2"
|
||||
|
||||
networks:
|
||||
@@ -25,19 +25,19 @@ services:
|
||||
ETCD_UNSUPPORTED_ARCH: arm64
|
||||
container_name: demo-etcd1
|
||||
hostname: etcd1
|
||||
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
|
||||
|
||||
etcd2:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd2
|
||||
hostname: etcd2
|
||||
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
|
||||
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
|
||||
|
||||
etcd3:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd3
|
||||
hostname: etcd3
|
||||
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
|
||||
|
||||
haproxy:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
|
||||
+194
-172
@@ -19,102 +19,96 @@ The haproxy listens on ports 5000 (connects to the primary) and 5001 (does load-
|
||||
|
||||
Example session:
|
||||
|
||||
$ docker-compose up -d
|
||||
Creating demo-haproxy ...
|
||||
Creating demo-patroni2 ...
|
||||
Creating demo-patroni1 ...
|
||||
Creating demo-patroni3 ...
|
||||
Creating demo-etcd2 ...
|
||||
Creating demo-etcd1 ...
|
||||
Creating demo-etcd3 ...
|
||||
Creating demo-haproxy
|
||||
Creating demo-patroni2
|
||||
Creating demo-patroni1
|
||||
Creating demo-patroni3
|
||||
Creating demo-etcd1
|
||||
Creating demo-etcd2
|
||||
Creating demo-etcd2 ... done
|
||||
$ docker compose up -d
|
||||
✔ Network patroni_demo Created
|
||||
✔ Container demo-etcd1 Started
|
||||
✔ Container demo-haproxy Started
|
||||
✔ Container demo-patroni1 Started
|
||||
✔ Container demo-patroni2 Started
|
||||
✔ Container demo-patroni3 Started
|
||||
✔ Container demo-etcd2 Started
|
||||
✔ Container demo-etcd3 Started
|
||||
|
||||
$ docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2
|
||||
e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1
|
||||
83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3
|
||||
922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3
|
||||
14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2
|
||||
110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1
|
||||
5af5e6e36028 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
a37bcec56726 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd3
|
||||
034ab73868a8 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni2
|
||||
03837736f710 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni3
|
||||
22815c3d85b3 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd2
|
||||
814b4304d132 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy
|
||||
6375b0ba2d0a patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni1
|
||||
aef8bf3ee91f patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd1
|
||||
|
||||
$ docker logs demo-patroni1
|
||||
2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul
|
||||
2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379
|
||||
2019-02-20 08:19:35,140 INFO: Lock owner: None; I am patroni1
|
||||
2019-02-20 08:19:35,174 INFO: trying to bootstrap a new cluster
|
||||
2024-08-26 09:04:33,547 INFO: Selected new etcd server http://172.29.0.3:2379
|
||||
2024-08-26 09:04:33,605 INFO: Lock owner: None; I am patroni1
|
||||
2024-08-26 09:04:33,693 INFO: trying to bootstrap a new cluster
|
||||
...
|
||||
2019-02-20 08:19:39,310 INFO: postmaster pid=37
|
||||
2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
||||
2019-02-20 08:19:39.321 UTC [37] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
||||
2019-02-20 08:19:39.353 UTC [39] LOG: database system was shut down at 2019-02-20 08:19:36 UTC
|
||||
2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up
|
||||
localhost:5432 - rejecting connections
|
||||
2019-02-20 08:19:39.369 UTC [37] LOG: database system is ready to accept connections
|
||||
2024-08-26 09:04:34.920 UTC [43] LOG: starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
|
||||
2024-08-26 09:04:34.921 UTC [43] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
||||
2024-08-26 09:04:34,922 INFO: postmaster pid=43
|
||||
2024-08-26 09:04:34.922 UTC [43] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
||||
2024-08-26 09:04:34.925 UTC [47] LOG: database system was shut down at 2024-08-26 09:04:34 UTC
|
||||
2024-08-26 09:04:34.928 UTC [43] LOG: database system is ready to accept connections
|
||||
localhost:5432 - accepting connections
|
||||
2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster
|
||||
2019-02-20 08:19:39,408 INFO: running post_bootstrap
|
||||
2019-02-20 08:19:39,432 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
|
||||
2019-02-20 08:19:39,515 INFO: initialized a new cluster
|
||||
2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1
|
||||
2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1
|
||||
2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock
|
||||
2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1
|
||||
localhost:5432 - accepting connections
|
||||
2024-08-26 09:04:34,938 INFO: establishing a new patroni heartbeat connection to postgres
|
||||
2024-08-26 09:04:34,992 INFO: running post_bootstrap
|
||||
2024-08-26 09:04:35,004 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
|
||||
2024-08-26 09:04:35,189 INFO: initialized a new cluster
|
||||
2024-08-26 09:04:35,328 INFO: no action. I am (patroni1), the leader with the lock
|
||||
2024-08-26 09:04:43,824 INFO: establishing a new patroni restapi connection to postgres
|
||||
2024-08-26 09:04:45,322 INFO: no action. I am (patroni1), the leader with the lock
|
||||
2024-08-26 09:04:55,320 INFO: no action. I am (patroni1), the leader with the lock
|
||||
...
|
||||
|
||||
$ docker exec -ti demo-patroni1 bash
|
||||
postgres@patroni1:~$ patronictl list
|
||||
+---------+----------+------------+--------+---------+----+-----------+
|
||||
| Cluster | Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+----------+------------+--------+---------+----+-----------+
|
||||
| demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 |
|
||||
| demo | patroni2 | 172.22.0.7 | | running | 1 | 0 |
|
||||
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
|
||||
+---------+----------+------------+--------+---------+----+-----------+
|
||||
+ Cluster: demo (7303838734793224214) --------+----+-----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+----------+------------+---------+-----------+----+-----------+
|
||||
| patroni1 | 172.29.0.2 | Leader | running | 1 | |
|
||||
| patroni2 | 172.29.0.6 | Replica | streaming | 1 | 0 |
|
||||
| patroni3 | 172.29.0.5 | Replica | streaming | 1 | 0 |
|
||||
+----------+------------+---------+-----------+----+-----------+
|
||||
|
||||
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
|
||||
/service/demo/config
|
||||
/service/demo/initialize
|
||||
/service/demo/leader
|
||||
/service/demo/members/
|
||||
/service/demo/members/patroni1
|
||||
/service/demo/members/patroni2
|
||||
/service/demo/members/patroni3
|
||||
/service/demo/optime/
|
||||
/service/demo/optime/leader
|
||||
/service/demo/status
|
||||
|
||||
postgres@patroni1:~$ etcdctl member list
|
||||
1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false
|
||||
8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true
|
||||
b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false
|
||||
2bf3e2ceda5d5960, started, etcd2, http://etcd2:2380, http://172.29.0.3:2379
|
||||
55b3264e129c7005, started, etcd3, http://etcd3:2380, http://172.29.0.7:2379
|
||||
acce7233f8ec127e, started, etcd1, http://etcd1:2380, http://172.29.0.8:2379
|
||||
|
||||
|
||||
postgres@patroni1:~$ exit
|
||||
|
||||
$ docker exec -ti demo-haproxy bash
|
||||
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W
|
||||
Password: postgres
|
||||
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
|
||||
psql (16.4 (Debian 16.4-1.pgdg120+1))
|
||||
Type "help" for help.
|
||||
|
||||
localhost/postgres=# select pg_is_in_recovery();
|
||||
postgres=# SELECT pg_is_in_recovery();
|
||||
pg_is_in_recovery
|
||||
───────────────────
|
||||
f
|
||||
(1 row)
|
||||
|
||||
localhost/postgres=# \q
|
||||
postgres=# \q
|
||||
|
||||
$postgres@haproxy:~ psql -h localhost -p 5001 -U postgres -W
|
||||
postgres@haproxy:~$ psql -h localhost -p 5001 -U postgres -W
|
||||
Password: postgres
|
||||
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
|
||||
psql (16.4 (Debian 16.4-1.pgdg120+1))
|
||||
Type "help" for help.
|
||||
|
||||
localhost/postgres=# select pg_is_in_recovery();
|
||||
postgres=# SELECT pg_is_in_recovery();
|
||||
pg_is_in_recovery
|
||||
───────────────────
|
||||
t
|
||||
@@ -128,80 +122,99 @@ The haproxy listens on ports 5000 (connects to the coordinator primary) and 5001
|
||||
Example session:
|
||||
|
||||
$ docker-compose -f docker-compose-citus.yml up -d
|
||||
Creating demo-work2-1 ... done
|
||||
Creating demo-work1-1 ... done
|
||||
Creating demo-etcd2 ... done
|
||||
Creating demo-etcd1 ... done
|
||||
Creating demo-coord3 ... done
|
||||
Creating demo-etcd3 ... done
|
||||
Creating demo-coord1 ... done
|
||||
Creating demo-haproxy ... done
|
||||
Creating demo-work2-2 ... done
|
||||
Creating demo-coord2 ... done
|
||||
Creating demo-work1-2 ... done
|
||||
✔ Network patroni_demo Created
|
||||
✔ Container demo-coord2 Started
|
||||
✔ Container demo-work2-2 Started
|
||||
✔ Container demo-etcd1 Started
|
||||
✔ Container demo-haproxy Started
|
||||
✔ Container demo-work1-1 Started
|
||||
✔ Container demo-work2-1 Started
|
||||
✔ Container demo-work1-2 Started
|
||||
✔ Container demo-coord1 Started
|
||||
✔ Container demo-etcd3 Started
|
||||
✔ Container demo-coord3 Started
|
||||
✔ Container demo-etcd2 Started
|
||||
|
||||
|
||||
$ docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
852d8885a612 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-coord3
|
||||
cdd692f947ab patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work1-2
|
||||
9f4e340b36da patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-etcd3
|
||||
d69c129a960a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd1
|
||||
c5849689b8cd patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord1
|
||||
c9d72bd6217d patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-1
|
||||
24b1b43efa05 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord2
|
||||
cb0cc2b4ca0a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-2
|
||||
9796c6b8aad5 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 5 seconds demo-work1-1
|
||||
8baccd74dcae patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd2
|
||||
353ec62a0187 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
79c95492fac9 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd3
|
||||
77eb82d0f0c1 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-1
|
||||
03dacd7267ef patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd1
|
||||
db9206c66f85 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd2
|
||||
9a0fef7b7dd4 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-2
|
||||
f06b031d99dc patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-2
|
||||
f7c58545f314 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord2
|
||||
383f9e7e188a patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-1
|
||||
f02e96dcc9d6 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord3
|
||||
6945834b7056 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord1
|
||||
b96ca42f785d patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy
|
||||
|
||||
|
||||
$ docker logs demo-coord1
|
||||
2023-01-05 15:09:31,295 INFO: Selected new etcd server http://172.27.0.4:2379
|
||||
2023-01-05 15:09:31,388 INFO: Lock owner: None; I am coord1
|
||||
2023-01-05 15:09:31,501 INFO: trying to bootstrap a new cluster
|
||||
2024-08-26 08:21:05,323 INFO: Selected new etcd server http://172.19.0.5:2379
|
||||
2024-08-26 08:21:05,339 INFO: No PostgreSQL configuration items changed, nothing to reload.
|
||||
2024-08-26 08:21:05,388 INFO: Lock owner: None; I am coord1
|
||||
2024-08-26 08:21:05,480 INFO: trying to bootstrap a new cluster
|
||||
...
|
||||
2023-01-05 15:09:45,096 INFO: postmaster pid=39
|
||||
2024-08-26 08:21:17,115 INFO: postmaster pid=35
|
||||
localhost:5432 - no response
|
||||
2023-01-05 15:09:45.137 UTC [39] LOG: starting PostgreSQL 15.1 (Debian 15.1-1.pgdg110+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 10.2.1-6) 10.2.1 20210110, 64-bit
|
||||
2023-01-05 15:09:45.137 UTC [39] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
||||
2023-01-05 15:09:45.152 UTC [39] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
||||
2023-01-05 15:09:45.177 UTC [43] LOG: database system was shut down at 2023-01-05 15:09:32 UTC
|
||||
2023-01-05 15:09:45.193 UTC [39] LOG: database system is ready to accept connections
|
||||
2024-08-26 08:21:17.127 UTC [35] LOG: starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
|
||||
2024-08-26 08:21:17.127 UTC [35] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
||||
2024-08-26 08:21:17.141 UTC [35] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
||||
2024-08-26 08:21:17.155 UTC [39] LOG: database system was shut down at 2024-08-26 08:21:05 UTC
|
||||
2024-08-26 08:21:17.182 UTC [35] LOG: database system is ready to accept connections
|
||||
2024-08-26 08:21:17,683 INFO: establishing a new patroni heartbeat connection to postgres
|
||||
2024-08-26 08:21:17,704 INFO: establishing a new patroni restapi connection to postgres
|
||||
localhost:5432 - accepting connections
|
||||
localhost:5432 - accepting connections
|
||||
2023-01-05 15:09:46,139 INFO: establishing a new patroni connection to the postgres cluster
|
||||
2023-01-05 15:09:46,208 INFO: running post_bootstrap
|
||||
2023-01-05 15:09:47.209 UTC [55] LOG: starting maintenance daemon on database 16386 user 10
|
||||
2023-01-05 15:09:47.209 UTC [55] CONTEXT: Citus maintenance daemon for database 16386 user 10
|
||||
2023-01-05 15:09:47,215 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
|
||||
2023-01-05 15:09:47.446 UTC [41] LOG: checkpoint starting: immediate force wait
|
||||
2023-01-05 15:09:47,466 INFO: initialized a new cluster
|
||||
2023-01-05 15:09:47,594 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
|
||||
2023-01-05 15:09:47,594 INFO: establishing a new patroni connection to the postgres cluster
|
||||
2023-01-05 15:09:47,467 INFO: Lock owner: coord1; I am coord1
|
||||
2023-01-05 15:09:47,613 DEBUG: query(SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default'), ('172.27.0.6', 5432))
|
||||
2023-01-05 15:09:47,924 INFO: no action. I am (coord1), the leader with the lock
|
||||
2023-01-05 15:09:51.282 UTC [41] LOG: checkpoint complete: wrote 1086 buffers (53.0%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.029 s, sync=3.746 s, total=3.837 s; sync files=280, longest=0.028 s, average=0.014 s; distance=8965 kB, estimate=8965 kB
|
||||
2023-01-05 15:09:51.283 UTC [41] LOG: checkpoint starting: immediate force wait
|
||||
2023-01-05 15:09:51.495 UTC [41] LOG: checkpoint complete: wrote 18 buffers (0.9%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.044 s, sync=0.091 s, total=0.212 s; sync files=15, longest=0.015 s, average=0.007 s; distance=67 kB, estimate=8076 kB
|
||||
2023-01-05 15:09:57,467 INFO: Lock owner: coord1; I am coord1
|
||||
2023-01-05 15:09:57,569 INFO: Assigning synchronous standby status to ['coord3']
|
||||
2024-08-26 08:21:18,202 INFO: running post_bootstrap
|
||||
2024-08-26 08:21:19.048 UTC [53] LOG: starting maintenance daemon on database 16385 user 10
|
||||
2024-08-26 08:21:19.048 UTC [53] CONTEXT: Citus maintenance daemon for database 16385 user 10
|
||||
2024-08-26 08:21:19,058 DEBUG: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'
|
||||
2024-08-26 08:21:19.250 UTC [37] LOG: checkpoint starting: immediate force wait
|
||||
2024-08-26 08:21:19,275 INFO: initialized a new cluster
|
||||
2024-08-26 08:21:22.946 UTC [37] LOG: checkpoint starting: immediate force wait
|
||||
2024-08-26 08:21:29,059 INFO: Lock owner: coord1; I am coord1
|
||||
2024-08-26 08:21:29,205 INFO: Enabled synchronous replication
|
||||
2024-08-26 08:21:29,206 DEBUG: query(SELECT groupid, nodename, nodeport, noderole, nodeid FROM pg_catalog.pg_dist_node, ())
|
||||
2024-08-26 08:21:29,206 INFO: establishing a new patroni citus connection to postgres
|
||||
2024-08-26 08:21:29,206 DEBUG: Adding the new task: PgDistTask({PgDistNode(nodeid=None,host=172.19.0.8,port=5432,role=primary)})
|
||||
2024-08-26 08:21:29,206 DEBUG: Adding the new task: PgDistTask({PgDistNode(nodeid=None,host=172.19.0.2,port=5432,role=primary)})
|
||||
2024-08-26 08:21:29,206 DEBUG: Adding the new task: PgDistTask({PgDistNode(nodeid=None,host=172.19.0.9,port=5432,role=primary)})
|
||||
2024-08-26 08:21:29,219 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default'), ('172.19.0.2', 5432, 1, 'primary'))
|
||||
2024-08-26 08:21:29,256 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default'), ('172.19.0.9', 5432, 2, 'primary'))
|
||||
2024-08-26 08:21:29,474 INFO: no action. I am (coord1), the leader with the lock
|
||||
2024-08-26 08:21:39,060 INFO: Lock owner: coord1; I am coord1
|
||||
2024-08-26 08:21:39,159 DEBUG: Adding the new task: PgDistTask({PgDistNode(nodeid=None,host=172.19.0.8,port=5432,role=primary), PgDistNode(nodeid=None,host=172.19.0.11,port=5432,role=secondary), PgDistNode(nodeid=None,host=172.19.0.7,port=5432,role=secondary)})
|
||||
2024-08-26 08:21:39,159 DEBUG: Adding the new task: PgDistTask({PgDistNode(nodeid=None,host=172.19.0.2,port=5432,role=primary), PgDistNode(nodeid=None,host=172.19.0.12,port=5432,role=secondary)})
|
||||
2024-08-26 08:21:39,159 DEBUG: Adding the new task: PgDistTask({PgDistNode(nodeid=None,host=172.19.0.6,port=5432,role=secondary), PgDistNode(nodeid=None,host=172.19.0.9,port=5432,role=primary)})
|
||||
2024-08-26 08:21:39,160 DEBUG: query(BEGIN, ())
|
||||
2024-08-26 08:21:39,160 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default'), ('172.19.0.11', 5432, 0, 'secondary'))
|
||||
2024-08-26 08:21:39,164 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default'), ('172.19.0.7', 5432, 0, 'secondary'))
|
||||
2024-08-26 08:21:39,166 DEBUG: query(COMMIT, ())
|
||||
2024-08-26 08:21:39,176 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default'), ('172.19.0.12', 5432, 1, 'secondary'))
|
||||
2024-08-26 08:21:39,191 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default'), ('172.19.0.6', 5432, 2, 'secondary'))
|
||||
2024-08-26 08:21:39,211 INFO: no action. I am (coord1), the leader with the lock
|
||||
2024-08-26 08:21:49,060 INFO: Lock owner: coord1; I am coord1
|
||||
2024-08-26 08:21:49,166 INFO: Setting synchronous replication to 1 of 2 (coord2, coord3)
|
||||
server signaled
|
||||
2023-01-05 15:09:57.574 UTC [39] LOG: received SIGHUP, reloading configuration files
|
||||
2023-01-05 15:09:57.580 UTC [39] LOG: parameter "synchronous_standby_names" changed to "coord3"
|
||||
2023-01-05 15:09:59,637 INFO: Synchronous standby status assigned to ['coord3']
|
||||
2023-01-05 15:09:59,638 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.2', 5432, 1))
|
||||
2023-01-05 15:09:59.690 UTC [67] LOG: standby "coord3" is now a synchronous standby with priority 1
|
||||
2023-01-05 15:09:59.690 UTC [67] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
|
||||
2023-01-05 15:09:59,694 INFO: no action. I am (coord1), the leader with the lock
|
||||
2023-01-05 15:09:59,704 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.8', 5432, 2))
|
||||
2023-01-05 15:10:07,625 INFO: no action. I am (coord1), the leader with the lock
|
||||
2023-01-05 15:10:17,579 INFO: no action. I am (coord1), the leader with the lock
|
||||
2024-08-26 08:21:49.170 UTC [35] LOG: received SIGHUP, reloading configuration files
|
||||
2024-08-26 08:21:49.171 UTC [35] LOG: parameter "synchronous_standby_names" changed to "ANY 1 (coord2,coord3)"
|
||||
2024-08-26 08:21:49.377 UTC [68] LOG: standby "coord2" is now a candidate for quorum synchronous standby
|
||||
2024-08-26 08:21:49.377 UTC [68] STATEMENT: START_REPLICATION SLOT "coord2" 0/3000000 TIMELINE 1
|
||||
2024-08-26 08:21:49.377 UTC [69] LOG: standby "coord3" is now a candidate for quorum synchronous standby
|
||||
2024-08-26 08:21:49.377 UTC [69] STATEMENT: START_REPLICATION SLOT "coord3" 0/4000000 TIMELINE 1
|
||||
2024-08-26 08:21:50,278 INFO: Setting leader to coord1, quorum to 1 of 2 (coord2, coord3)
|
||||
2024-08-26 08:21:50,390 INFO: no action. I am (coord1), the leader with the lock
|
||||
2024-08-26 08:21:59,159 INFO: no action. I am (coord1), the leader with the lock
|
||||
...
|
||||
|
||||
$ docker exec -ti demo-haproxy bash
|
||||
postgres@haproxy:~$ etcdctl member list
|
||||
1bab629f01fa9065, started, etcd3, http://etcd3:2380, http://172.27.0.10:2379
|
||||
8ecb6af518d241cc, started, etcd2, http://etcd2:2380, http://172.27.0.4:2379
|
||||
b2e169fcb8a34028, started, etcd1, http://etcd1:2380, http://172.27.0.7:2379
|
||||
2b28411e74c0c281, started, etcd3, http://etcd3:2380, http://172.30.0.4:2379
|
||||
6c70137d27cfa6c1, started, etcd2, http://etcd2:2380, http://172.30.0.5:2379
|
||||
a28f9a70ebf21304, started, etcd1, http://etcd1:2380, http://172.30.0.6:2379
|
||||
|
||||
postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo
|
||||
/service/demo/0/config
|
||||
@@ -229,7 +242,7 @@ Example session:
|
||||
|
||||
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
|
||||
Password for user postgres: postgres
|
||||
psql (15.1 (Debian 15.1-1.pgdg110+1))
|
||||
psql (16.4 (Debian 16.4-1.pgdg120+1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
@@ -240,67 +253,76 @@ Example session:
|
||||
(1 row)
|
||||
|
||||
citus=# table pg_dist_node;
|
||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||
--------+---------+------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
||||
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
|
||||
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
|
||||
3 | 2 | 172.27.0.8 | 5432 | default | t | t | primary | default | t | t
|
||||
(3 rows)
|
||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||
--------+---------+-------------+----------+----------+-------------+----------+-----------+-------------+----------------+------------------
|
||||
1 | 0 | 172.19.0.8 | 5432 | default | t | t | primary | default | t | f
|
||||
2 | 1 | 172.19.0.2 | 5432 | default | t | t | primary | default | t | t
|
||||
3 | 2 | 172.19.0.9 | 5432 | default | t | t | primary | default | t | t
|
||||
4 | 0 | 172.19.0.11 | 5432 | default | t | t | secondary | default | t | f
|
||||
5 | 0 | 172.19.0.7 | 5432 | default | t | t | secondary | default | t | f
|
||||
6 | 1 | 172.19.0.12 | 5432 | default | f | t | secondary | default | f | t
|
||||
7 | 2 | 172.19.0.6 | 5432 | default | f | t | secondary | default | f | t
|
||||
(7 rows)
|
||||
|
||||
citus=# \q
|
||||
|
||||
postgres@haproxy:~$ patronictl list
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
|
||||
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
|
||||
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
|
||||
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-2 | 172.27.0.8 | Leader | running | 1 | |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo ----------+----------------+-----------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+----------------+-----------+----+-----------+
|
||||
| 0 | coord1 | 172.19.0.8 | Leader | running | 1 | |
|
||||
| 0 | coord2 | 172.19.0.7 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 0 | coord3 | 172.19.0.11 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 1 | work1-1 | 172.19.0.12 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 1 | work1-2 | 172.19.0.2 | Leader | running | 1 | |
|
||||
| 2 | work2-1 | 172.19.0.6 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 2 | work2-2 | 172.19.0.9 | Leader | running | 1 | |
|
||||
+-------+---------+-------------+----------------+-----------+----+-----------+
|
||||
|
||||
|
||||
postgres@haproxy:~$ patronictl switchover --group 2 --force
|
||||
Current cluster topology
|
||||
+ Citus cluster: demo (group: 2, 7185185529556963355) +-----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+-------------+--------------+---------+----+-----------+
|
||||
| work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
|
||||
| work2-2 | 172.27.0.8 | Leader | running | 1 | |
|
||||
+---------+-------------+--------------+---------+----+-----------+
|
||||
2023-01-05 15:29:29.54204 Successfully switched over to "work2-1"
|
||||
+ Citus cluster: demo (group: 2, 7185185529556963355) -------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+-------------+---------+---------+----+-----------+
|
||||
| work2-1 | 172.27.0.11 | Leader | running | 1 | |
|
||||
| work2-2 | 172.27.0.8 | Replica | stopped | | unknown |
|
||||
+---------+-------------+---------+---------+----+-----------+
|
||||
+ Citus cluster: demo (group: 2, 7407360296219029527) ---+-----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+----------------+-----------+----+-----------+
|
||||
| work2-1 | 172.19.0.6 | Quorum Standby | streaming | 1 | 0 |
|
||||
| work2-2 | 172.19.0.9 | Leader | running | 1 | |
|
||||
+---------+------------+----------------+-----------+----+-----------+
|
||||
2024-08-26 08:31:45.92277 Successfully switched over to "work2-1"
|
||||
+ Citus cluster: demo (group: 2, 7407360296219029527) ------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+---------+---------+----+-----------+
|
||||
| work2-1 | 172.19.0.6 | Leader | running | 1 | |
|
||||
| work2-2 | 172.19.0.9 | Replica | stopped | | unknown |
|
||||
+---------+------------+---------+---------+----+-----------+
|
||||
|
||||
postgres@haproxy:~$ patronictl list
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
|
||||
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
|
||||
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
|
||||
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-1 | 172.27.0.11 | Leader | running | 2 | |
|
||||
| 2 | work2-2 | 172.27.0.8 | Sync Standby | running | 2 | 0 |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo ----------+----------------+-----------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+----------------+-----------+----+-----------+
|
||||
| 0 | coord1 | 172.19.0.8 | Leader | running | 1 | |
|
||||
| 0 | coord2 | 172.19.0.7 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 0 | coord3 | 172.19.0.11 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 1 | work1-1 | 172.19.0.12 | Quorum Standby | streaming | 1 | 0 |
|
||||
| 1 | work1-2 | 172.19.0.2 | Leader | running | 1 | |
|
||||
| 2 | work2-1 | 172.19.0.6 | Leader | running | 2 | |
|
||||
| 2 | work2-2 | 172.19.0.9 | Quorum Standby | streaming | 2 | 0 |
|
||||
+-------+---------+-------------+----------------+-----------+----+-----------+
|
||||
|
||||
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
|
||||
Password for user postgres: postgres
|
||||
psql (15.1 (Debian 15.1-1.pgdg110+1))
|
||||
psql (16.4 (Debian 16.4-1.pgdg120+1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
citus=# table pg_dist_node;
|
||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||
--------+---------+-------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
||||
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
|
||||
3 | 2 | 172.27.0.11 | 5432 | default | t | t | primary | default | t | t
|
||||
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
|
||||
(3 rows)
|
||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||
--------+---------+-------------+----------+----------+-------------+----------+-----------+-------------+----------------+------------------
|
||||
1 | 0 | 172.19.0.8 | 5432 | default | t | t | primary | default | t | f
|
||||
4 | 0 | 172.19.0.11 | 5432 | default | t | t | secondary | default | t | f
|
||||
5 | 0 | 172.19.0.7 | 5432 | default | t | t | secondary | default | t | f
|
||||
6 | 1 | 172.19.0.12 | 5432 | default | f | t | secondary | default | f | t
|
||||
3 | 2 | 172.19.0.6 | 5432 | default | t | t | primary | default | t | t
|
||||
2 | 1 | 172.19.0.2 | 5432 | default | t | t | primary | default | t | t
|
||||
8 | 2 | 172.19.0.9 | 5432 | default | f | t | secondary | default | f | t
|
||||
(7 rows)
|
||||
|
||||
@@ -13,6 +13,8 @@ readonly PATRONI_NAMESPACE="${PATRONI_NAMESPACE%/}"
|
||||
DOCKER_IP=$(hostname --ip-address)
|
||||
readonly DOCKER_IP
|
||||
|
||||
export DUMB_INIT_SETSID=0
|
||||
|
||||
case "$1" in
|
||||
haproxy)
|
||||
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
|
||||
@@ -36,7 +38,7 @@ EOT
|
||||
exec dumb-init "$@"
|
||||
;;
|
||||
etcd)
|
||||
exec "$@" -advertise-client-urls "http://$DOCKER_IP:2379"
|
||||
exec "$@" --auto-compaction-retention=1 -advertise-client-urls "http://$DOCKER_IP:2379"
|
||||
;;
|
||||
zookeeper)
|
||||
exec /usr/share/zookeeper/bin/zkServer.sh start-foreground
|
||||
@@ -72,4 +74,4 @@ export PATRONI_SUPERUSER_SSLKEY="${PATRONI_SUPERUSER_SSLKEY:-$PGSSLKEY}"
|
||||
export PATRONI_SUPERUSER_SSLCERT="${PATRONI_SUPERUSER_SSLCERT:-$PGSSLCERT}"
|
||||
export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}"
|
||||
|
||||
exec python3 /patroni.py postgres0.yml
|
||||
exec dumb-init python3 /patroni.py postgres0.yml
|
||||
|
||||
+29
-11
@@ -14,15 +14,28 @@ Global/Universal
|
||||
|
||||
Log
|
||||
---
|
||||
- **PATRONI\_LOG\_TYPE**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
|
||||
- **PATRONI\_LOG\_LEVEL**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||
- **PATRONI\_LOG\_TRACEBACK\_LEVEL**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **PATRONI\_LOG\_LEVEL=DEBUG**.
|
||||
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
|
||||
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. If the log type is **plain**, the log format should be a string.
|
||||
Refer to `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
|
||||
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
|
||||
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
|
||||
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
|
||||
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
|
||||
Default value is **%(asctime)s %(levelname)s: %(message)s**
|
||||
- **PATRONI\_LOG\_DATEFORMAT**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
|
||||
- **PATRONI\_LOG\_STATIC\_FIELDS**: add additional fields to the log. This option is only available when the log type is set to **json**. Example ``PATRONI_LOG_STATIC_FIELDS="{app: patroni}"``
|
||||
- **PATRONI\_LOG\_MAX\_QUEUE\_SIZE**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
|
||||
- **PATRONI\_LOG\_DIR**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this env variable, the application will retain 4 25MB logs by default. You can tune those retention values with `PATRONI_LOG_FILE_NUM` and `PATRONI_LOG_FILE_SIZE` (see below).
|
||||
- **PATRONI\_LOG\_MODE**: Permissions for log files (for example, ``0644``). If not specified, permissions will be set based on the current umask value.
|
||||
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
|
||||
- **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling.
|
||||
- **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"``
|
||||
- **PATRONI\_LOG\_DEDUPLICATE\_HEARTBEAT\_LOGS**: If set to ``true``, successive heartbeat logs that are identical shall not be output. Default value is ``false``.
|
||||
|
||||
.. warning::
|
||||
The time the HA loop executes at can be very valuable information in diagnosing failovers due to resource exhaustion and similar problems. When ``PATRONI_LOG_DEDUPLICATE_HEARTBEAT_LOGS`` is set to ``true`` there will be no log generated for the HA loop execution (unless the leader changes) and hence this potentially useful information will not be available from the logs.
|
||||
|
||||
Citus
|
||||
-----
|
||||
@@ -46,9 +59,9 @@ Consul
|
||||
- **PATRONI\_CONSUL\_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/>`__)
|
||||
- **PATRONI\_CONSUL\_CHECKS**: (optional) list of Consul health checks used for the session. By default an empty list is used.
|
||||
- **PATRONI\_CONSUL\_REGISTER\_SERVICE**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, primary, replica, or standby-leader depending on the node's role. Defaults to **false**
|
||||
- **PATRONI\_CONSUL\_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.
|
||||
- **PATRONI\_CONSUL\_SERVICE\_TAGS**: (optional) additional static tags to add to the Consul service apart from the role (``primary``/``replica``/``standby-leader``). By default an empty list is used.
|
||||
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_INTERVAL**: (optional) how often to perform health check against registered url
|
||||
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_TLS\_SERVER\_NAME**: (optional) overide SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
|
||||
- **PATRONI\_CONSUL\_SERVICE\_CHECK\_TLS\_SERVER\_NAME**: (optional) override SNI host when connecting via TLS, see also `consul agent check API reference <https://www.consul.io/api-docs/agent/check#tlsservername>`__.
|
||||
|
||||
Etcd
|
||||
----
|
||||
@@ -72,7 +85,7 @@ Etcdv3
|
||||
Environment names for Etcdv3 are similar as for Etcd, you just need to use ``ETCD3`` instead of ``ETCD`` in the variable name. Example: ``PATRONI_ETCD3_HOST``, ``PATRONI_ETCD3_CACERT``, and so on.
|
||||
|
||||
.. 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 Etcdv3 just by updating Patroni configuration.
|
||||
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 Etcdv3 just by updating Patroni configuration. In addition, Patroni uses Etcd's gRPC-gateway (proxy) to communicate with the V3 API, which means that TLS common name authentication is not possible.
|
||||
|
||||
|
||||
ZooKeeper
|
||||
@@ -85,6 +98,7 @@ ZooKeeper
|
||||
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
|
||||
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||
- **PATRONI\_ZOOKEEPER\_AUTH\_DATA**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary.
|
||||
|
||||
.. note::
|
||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||
@@ -103,11 +117,12 @@ 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 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\_BOOTSTRAP\_LABELS**: (optional) Labels in format ``{label1: value1, label2: value2}``. These labels will be assigned to a Patroni pod when its state is either ``initializing new cluster``, ``running custom bootstrap script``, ``starting after custom bootstrap`` or ``creating replica``.
|
||||
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing role (`primary`, `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 `primary`. Default value is `primary`.
|
||||
- **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\_STANDBY\_LEADER\_LABEL\_VALUE**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``primary``.
|
||||
- **PATRONI\_KUBERNETES\_TMP\_ROLE\_LABEL**: (optional) name of the temporary label containing role (`primary` 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.
|
||||
@@ -145,9 +160,10 @@ PostgreSQL
|
||||
- **PATRONI\_REPLICATION\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **PATRONI\_REPLICATION\_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_REPLICATION_SSLKEY``.
|
||||
- **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_REPLICATION\_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 or more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REPLICATION\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REPLICATION\_SSLNEGOTIATION**: (optional) maps to the `sslnegotiation <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION>`__ connection parameter, which controls how SSL encryption is negotiated with the server, if SSL is used.
|
||||
- **PATRONI\_REPLICATION\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **PATRONI\_REPLICATION\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
|
||||
@@ -156,9 +172,10 @@ PostgreSQL
|
||||
- **PATRONI\_SUPERUSER\_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\_SUPERUSER\_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_SUPERUSER_SSLKEY``.
|
||||
- **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_SUPERUSER\_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 or more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_SUPERUSER\_SSLNEGOTIATION**: (optional) maps to the `sslnegotiation <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION>`__ connection parameter, which controls how SSL encryption is negotiated with the server, if SSL is used.
|
||||
- **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**: (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.
|
||||
@@ -167,9 +184,10 @@ PostgreSQL
|
||||
- **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``.
|
||||
- **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_REWIND\_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 or more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REWIND\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
- **PATRONI\_REWIND\_SSLNEGOTIATION**: (optional) maps to the `sslnegotiation <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION>`__ connection parameter, which controls how SSL encryption is negotiated with the server, if SSL is used.
|
||||
- **PATRONI\_REWIND\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
|
||||
- **PATRONI\_REWIND\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
|
||||
|
||||
|
||||
+3
-5
@@ -6,12 +6,10 @@ Introduction
|
||||
|
||||
Patroni is a template for high availability (HA) PostgreSQL solutions using Python. Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
|
||||
|
||||
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
|
||||
|
||||
For additional background info, see:
|
||||
|
||||
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
|
||||
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
|
||||
* `Feb. 2016 Zalando Tech blog post <https://engineering.zalando.com/posts/2016/02/zalandos-patroni-a-template-for-high-availability-postgresql.html>`__
|
||||
|
||||
|
||||
Development Status
|
||||
@@ -39,7 +37,7 @@ perfectly fine. You can add more standby nodes later.
|
||||
Running and Configuring
|
||||
-----------------------
|
||||
|
||||
The following section assumes Patroni repository as being cloned from https://github.com/zalando/patroni. Namely, you
|
||||
The following section assumes Patroni repository as being cloned from https://github.com/patroni/patroni. Namely, you
|
||||
will need example configuration files `postgres0.yml` and `postgres1.yml`. If you installed Patroni with pip, you can
|
||||
obtain those files from the git repository and replace `./patroni.py` below with `patroni` command.
|
||||
|
||||
@@ -69,7 +67,7 @@ run:
|
||||
YAML Configuration
|
||||
------------------
|
||||
|
||||
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>`__.
|
||||
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/patroni/patroni/blob/master/postgres0.yml>`__.
|
||||
|
||||
|
||||
Environment Configuration
|
||||
|
||||
+71
-60
@@ -34,6 +34,8 @@ There are only a few simple rules you need to follow:
|
||||
|
||||
After that you just need to start Patroni and it will handle the rest:
|
||||
|
||||
0. Patroni will set ``bootstrap.dcs.synchronous_mode`` to :ref:`quorum <quorum_mode>`
|
||||
if it is not explicitly set to any other value.
|
||||
1. ``citus`` extension will be automatically added to ``shared_preload_libraries``.
|
||||
2. If ``max_prepared_transactions`` isn't explicitly set in the global
|
||||
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
|
||||
@@ -56,7 +58,7 @@ patronictl
|
||||
----------
|
||||
|
||||
Coordinator and worker clusters are physically different PostgreSQL/Patroni
|
||||
clusters that are just logically groupped together using the
|
||||
clusters that are just logically grouped 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.
|
||||
@@ -77,36 +79,36 @@ It results in two major differences in :ref:`patronictl` behaviour when
|
||||
An example of :ref:`patronictl_list` output for the Citus cluster::
|
||||
|
||||
postgres@coord1:~$ patronictl list demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| 0 | coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
| 1 | work1-1 | 172.27.0.8 | Sync Standby | running | 1 | 0 |
|
||||
| 1 | work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
||||
| 2 | work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo ----------+----------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| 0 | coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
| 1 | work1-1 | 172.27.0.8 | Quorum Standby | running | 1 | 0 |
|
||||
| 1 | work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
||||
| 2 | work2-1 | 172.27.0.5 | Quorum Standby | running | 1 | 0 |
|
||||
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
||||
|
||||
If we add the ``--group`` option, the output will change to::
|
||||
|
||||
postgres@coord1:~$ patronictl list demo --group 0
|
||||
+ Citus cluster: demo (group: 0, 7179854923829112860) -----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+--------+-------------+--------------+---------+----+-----------+
|
||||
| coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||
| coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
+--------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo (group: 0, 7179854923829112860) -+-----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+--------+-------------+----------------+---------+----+-----------+
|
||||
| coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
||||
| coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
+--------+-------------+----------------+---------+----+-----------+
|
||||
|
||||
postgres@coord1:~$ patronictl list demo --group 1
|
||||
+ Citus cluster: demo (group: 1, 7179854923881963547) -----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+--------------+---------+----+-----------+
|
||||
| work1-1 | 172.27.0.8 | Sync Standby | running | 1 | 0 |
|
||||
| work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
||||
+---------+------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo (group: 1, 7179854923881963547) -+-----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+----------------+---------+----+-----------+
|
||||
| work1-1 | 172.27.0.8 | Quorum Standby | running | 1 | 0 |
|
||||
| work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
||||
+---------+------------+----------------+---------+----+-----------+
|
||||
|
||||
Citus worker switchover
|
||||
-----------------------
|
||||
@@ -122,30 +124,30 @@ new primary worker node is ready to accept read-write queries.
|
||||
An example of :ref:`patronictl_switchover` on the worker cluster::
|
||||
|
||||
postgres@coord1:~$ patronictl switchover demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| 0 | coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
||||
| 1 | work1-2 | 172.27.0.2 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo ----------+----------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| 0 | coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
||||
| 1 | work1-2 | 172.27.0.2 | Quorum Standby | running | 1 | 0 |
|
||||
| 2 | work2-1 | 172.27.0.5 | Quorum Standby | running | 1 | 0 |
|
||||
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
||||
Citus group: 2
|
||||
Primary [work2-2]:
|
||||
Candidate ['work2-1'] []:
|
||||
When should the switchover take place (e.g. 2022-12-22T08:02 ) [now]:
|
||||
When should the switchover take place (e.g. 2024-08-26T08:02 ) [now]:
|
||||
Current cluster topology
|
||||
+ Citus cluster: demo (group: 2, 7179854924063375386) -----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+--------------+---------+----+-----------+
|
||||
| work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||
| work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+---------+------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo (group: 2, 7179854924063375386) -+-----------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+----------------+---------+----+-----------+
|
||||
| work2-1 | 172.27.0.5 | Quorum Standby | running | 1 | 0 |
|
||||
| work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+---------+------------+----------------+---------+----+-----------+
|
||||
Are you sure you want to switchover cluster demo, demoting current primary work2-2? [y/N]: y
|
||||
2022-12-22 07:02:40.33003 Successfully switched over to "work2-1"
|
||||
2024-08-26 07:02:40.33003 Successfully switched over to "work2-1"
|
||||
+ Citus cluster: demo (group: 2, 7179854924063375386) ------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
+---------+------------+---------+---------+----+-----------+
|
||||
@@ -154,32 +156,41 @@ An example of :ref:`patronictl_switchover` on the worker cluster::
|
||||
+---------+------------+---------+---------+----+-----------+
|
||||
|
||||
postgres@coord1:~$ patronictl list demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| 0 | coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
||||
| 1 | work1-2 | 172.27.0.2 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | work2-1 | 172.27.0.5 | Leader | running | 2 | |
|
||||
| 2 | work2-2 | 172.27.0.7 | Sync Standby | running | 2 | 0 |
|
||||
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: demo ----------+----------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||
| 0 | coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
||||
| 1 | work1-2 | 172.27.0.2 | Quorum Standby | running | 1 | 0 |
|
||||
| 2 | work2-1 | 172.27.0.5 | Leader | running | 2 | |
|
||||
| 2 | work2-2 | 172.27.0.7 | Quorum Standby | running | 2 | 0 |
|
||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
||||
|
||||
And this is how it looks on the coordinator side::
|
||||
|
||||
# The worker primary notifies the coordinator that it is going to execute "pg_ctl stop".
|
||||
2022-12-22 07:02:38,636 DEBUG: query("BEGIN")
|
||||
2022-12-22 07:02:38,636 DEBUG: query("SELECT pg_catalog.citus_update_node(3, '172.27.0.7-demoted', 5432, true, 10000)")
|
||||
2024-08-26 07:02:38,636 DEBUG: query(BEGIN, ())
|
||||
2024-08-26 07:02:38,636 DEBUG: query(SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s), (3, '172.19.0.7-demoted', 5432, 10000))
|
||||
# From this moment all application traffic on the coordinator to the worker group 2 is paused.
|
||||
|
||||
# The old worker primary is assigned as a secondary.
|
||||
2024-08-26 07:02:40,084 DEBUG: query(SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s), (7, '172.19.0.7', 5432, 10000))
|
||||
|
||||
# The future worker primary notifies the coordinator that it acquired the leader lock in DCS and about to run "pg_ctl promote".
|
||||
2022-12-22 07:02:40,085 DEBUG: query("SELECT pg_catalog.citus_update_node(3, '172.27.0.5', 5432)")
|
||||
2024-08-26 07:02:40,085 DEBUG: query(SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s), (3, '172.19.0.5', 5432, 10000))
|
||||
|
||||
# The new worker primary just finished promote and notifies coordinator that it is ready to accept read-write traffic.
|
||||
2022-12-22 07:02:41,485 DEBUG: query("COMMIT")
|
||||
2024-08-26 07:02:41,485 DEBUG: query(COMMIT, ())
|
||||
# From this moment the application traffic on the coordinator to the worker group 2 is unblocked.
|
||||
|
||||
Secondary nodes
|
||||
---------------
|
||||
|
||||
Starting from Patroni v4.0.0 Citus secondary nodes without ``noloadbalance`` :ref:`tag <tags_settings>` are also registered in ``pg_dist_node``.
|
||||
However, to use secondary nodes for read-only queries applications need to change `citus.use_secondary_nodes <https://docs.citusdata.com/en/latest/develop/api_guc.html#citus-use-secondary-nodes-enum>`__ GUC.
|
||||
|
||||
Peek into DCS
|
||||
-------------
|
||||
|
||||
@@ -335,7 +346,7 @@ new Kubernetes objects ConfigMaps or Endpoints, it automatically puts the
|
||||
You can find a complete example of Patroni deployment on Kubernetes with Citus
|
||||
support in the `kubernetes`__ folder of the Patroni repository.
|
||||
|
||||
__ https://github.com/zalando/patroni/tree/master/kubernetes
|
||||
__ https://github.com/patroni/patroni/tree/master/kubernetes
|
||||
|
||||
There are two important files for you:
|
||||
|
||||
|
||||
+38
-14
@@ -21,6 +21,8 @@ import os
|
||||
|
||||
import sys
|
||||
|
||||
from sphinx.application import ENV_PICKLE_FILENAME
|
||||
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
from patroni.version import __version__
|
||||
@@ -75,8 +77,8 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'Patroni'
|
||||
copyright = '2015 Compose, Zalando SE'
|
||||
author = 'Zalando SE'
|
||||
copyright = '2025 Compose, Zalando SE, Patroni Contributors'
|
||||
author = 'Patroni Contributors'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
@@ -114,9 +116,6 @@ todo_include_todos = True
|
||||
|
||||
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_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
@@ -132,7 +131,7 @@ html_static_path = ['_static']
|
||||
# Replace "source" links with "edit on GitHub" when using rtd theme
|
||||
html_context = {
|
||||
'display_github': True,
|
||||
'github_user': 'zalando',
|
||||
'github_user': 'patroni',
|
||||
'github_repo': 'patroni',
|
||||
'github_version': 'master',
|
||||
'conf_py_path': '/docs/',
|
||||
@@ -189,7 +188,7 @@ latex_elements = {
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'Patroni.tex', 'Patroni Documentation',
|
||||
'Zalando SE', 'manual'),
|
||||
'Patroni Contributors', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
@@ -243,13 +242,24 @@ 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'],
|
||||
'latex': ['modules/'],
|
||||
'epub': ['modules/'],
|
||||
}
|
||||
# Internal holding list, anything added here will always be excluded
|
||||
_docs_to_remove = []
|
||||
|
||||
|
||||
def config_inited(app, config):
|
||||
"""Run during Sphinx `config-inited` phase.
|
||||
|
||||
rtd reuses the environment, and there is no way to customize this behavior.
|
||||
Thus we remove the saved env.
|
||||
"""
|
||||
pickle_file = os.path.join(app.doctreedir, ENV_PICKLE_FILENAME)
|
||||
if on_rtd and os.path.exists(pickle_file):
|
||||
os.remove(pickle_file)
|
||||
|
||||
|
||||
def builder_inited(app):
|
||||
"""Run during Sphinx `builder-inited` phase.
|
||||
|
||||
@@ -263,14 +273,28 @@ def builder_inited(app):
|
||||
_docs_to_remove.extend(exclude_from_builder[app.builder.name])
|
||||
|
||||
|
||||
def _to_be_removed(doc):
|
||||
for remove in _docs_to_remove:
|
||||
if doc.startswith(remove):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
to_remove = set()
|
||||
if hasattr(env, 'found_docs'):
|
||||
for doc in env.found_docs:
|
||||
if _to_be_removed(doc):
|
||||
to_remove.add(doc)
|
||||
added.difference_update(to_remove)
|
||||
changed.difference_update(to_remove)
|
||||
removed.update(to_remove)
|
||||
if hasattr(env, 'project'):
|
||||
env.project.docnames.difference_update(to_remove)
|
||||
return []
|
||||
|
||||
|
||||
@@ -282,8 +306,7 @@ def doctree_read(app, doctree):
|
||||
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:
|
||||
if _to_be_removed(str(e[1])):
|
||||
toc_tree_node['entries'].remove(e)
|
||||
|
||||
|
||||
@@ -304,6 +327,7 @@ def setup(app):
|
||||
app.add_stylesheet('custom.css')
|
||||
|
||||
# Run extra steps to remove module docs when running with a non-html builder
|
||||
app.connect('config-inited', config_inited)
|
||||
app.connect('builder-inited', builder_inited)
|
||||
app.connect('env-get-outdated', env_get_outdated)
|
||||
app.connect('doctree-read', doctree_read)
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
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>`__.
|
||||
Also please double check if the issue already exists in our `Issues Tracker <https://github.com/patroni/patroni/issues>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
@@ -21,19 +21,20 @@ In order to change the dynamic configuration you can use either :ref:`patronictl
|
||||
|
||||
|
||||
- **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.
|
||||
- **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 frequently 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**: turns on synchronous replication mode. Possible values: ``off``, ``on``, ``quorum``. In this mode the leader takes care of management of ``synchronous_standby_names``, and only the last known leader, or one of synchronous replicas, are allowed to participate in leader race. 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.
|
||||
- **synchronous\_node\_count**: if ``synchronous_mode`` is enabled, this parameter is used by Patroni to manage the precise number of synchronous standby instances and adjusts the state in DCS and the ``synchronous_standby_names`` parameter in PostgreSQL as members join and leave. If the parameter is set to a value higher than the number of eligible nodes, it will be automatically adjusted. Defaults to ``1``.
|
||||
- **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\_pg\_rewind**: whether or not to use pg_rewind. Defaults to `false`. Note that either the cluster must be initialized with ``data page checksums`` (``--data-checksums`` option for ``initdb``) and/or ``wal_log_hints`` must be set to ``on``, or ``pg_rewind`` will not work.
|
||||
- **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.
|
||||
- **parameters**: configuration parameters (GUCs) for Postgres in format ``{max_connections: 100, wal_level: "replica", max_wal_senders: 10, wal_log_hints: "on"}``. 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.
|
||||
|
||||
@@ -55,13 +56,15 @@ In order to change the dynamic configuration you can use either :ref:`patronictl
|
||||
- **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
|
||||
|
||||
- **member_slots_ttl**: retention time of physical replication slots for replicas when they are shut down. Default value: `30min`. Set it to `0` if you want to keep the old behavior (when the member key expires from DCS, the slot is immediately removed). The feature works only starting from PostgreSQL 11.
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. With PostgreSQL 11 onwards permanent physical slots are created on all nodes and their position is advanced every **loop_wait** seconds. For PostgreSQL versions older than 11 permanent physical replication slots are maintained only on the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent replication slots requires **postgresql.use_slots** to be set to ``true``. If there are permanent logical replication slots defined Patroni will automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
|
||||
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current 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``.
|
||||
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``. If the slot is physical, you can optionally define ``cluster_type``.
|
||||
- **database**: the database name where logical slots should be created.
|
||||
- **plugin**: the plugin name for the logical slot.
|
||||
- **cluster_type**: the type of cluster (``primary`` or ``standby``) the slot should only be created on, otherwise it will not be created or an already existing slot will be dropped.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -91,7 +94,7 @@ Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
|
||||
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:
|
||||
Note: When running PostgreSQL v11 or newer Patroni maintains physical replication slots on all nodes that could potentially become a leader, so that replica nodes keep WAL segments reserved if they are potentially required by other nodes. In case the node is absent and its member key in DCS gets expired, the corresponding replication slot is dropped after ``member_slots_ttl`` (default value is `30min`). You can increase or decrease retention based on your needs. Alternatively, if your cluster topology is static (fixed number of nodes that never change their names) you can configure permanent physical replication slots with names corresponding to the names of the nodes to avoid slots removal and recycling of WAL files while replica is temporarily down:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
@@ -107,4 +110,8 @@ Note: if cluster topology is static (fixed number of nodes that never change the
|
||||
|
||||
.. 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.
|
||||
An exception to that rule are physical slots that match the Patroni member names (created and maintained by Patroni). Those will be synchronized among all nodes as they are used for replication among them.
|
||||
|
||||
|
||||
.. warning::
|
||||
Setting ``nostream`` tag on standby disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas if any.
|
||||
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
.. _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?
|
||||
There are two options:
|
||||
|
||||
1. You can tune ``member_slots_ttl`` (default value ``30min``, available since Patroni ``4.0.0`` and PostgreSQL 11 onwards) and replication slots for absent members will not be removed when the members downtime is shorter than the configured threshold.
|
||||
2. You can configure permanent physical replication slots 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 responsibility** 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.
|
||||
@@ -109,7 +109,7 @@ digraph G {
|
||||
subgraph cluster_process_healthy_cluster {
|
||||
label = "process_healthy_cluster"
|
||||
"healthy_has_lock" [label="Am I the owner of the leader lock?", shape=diamond]
|
||||
"healthy_is_leader" [label="Is Postgres running as master?", shape=diamond]
|
||||
"healthy_is_leader" [label="Is Postgres running as primary?", shape=diamond]
|
||||
"healthy_no_lock" [label="Follow the leader (async,\ncreate/update recovery.conf and restart if necessary)"]
|
||||
"healthy_has_lock" -> "healthy_no_lock" [label="no" color="red"]
|
||||
"healthy_has_lock" -> "healthy_update_leader_lock" [label="yes" color="green"]
|
||||
@@ -119,7 +119,7 @@ digraph G {
|
||||
"healthy_update_success" -> "healthy_is_leader" [label="yes" color="green"]
|
||||
"healthy_update_success" -> "healthy_demote" [label="no" color="red"]
|
||||
"healthy_demote" [label="Demote (async,\nrestart in read-only)"]
|
||||
"healthy_failover" [label="Promote Postgres to master"]
|
||||
"healthy_failover" [label="Promote Postgres to primary"]
|
||||
"healthy_is_leader" -> "healthy_failover" [label="no" color="red"]
|
||||
}
|
||||
"healthy_demote" -> "update_member"
|
||||
@@ -134,10 +134,10 @@ digraph G {
|
||||
"unhealthy_leader_race" [label="Try to create leader key"]
|
||||
"unhealthy_leader_race" -> "unhealthy_acquire_lock"
|
||||
"unhealthy_acquire_lock" [label="Was I able to get the lock?", shape="diamond"]
|
||||
"unhealthy_is_leader" [label="Is Postgres running as master?", shape=diamond]
|
||||
"unhealthy_is_leader" [label="Is Postgres running as primary?", shape=diamond]
|
||||
"unhealthy_acquire_lock" -> "unhealthy_is_leader" [label="yes" color="green"]
|
||||
"unhealthy_is_leader" -> "unhealthy_promote" [label="no" color="red"]
|
||||
"unhealthy_promote" [label="Promote to master"]
|
||||
"unhealthy_promote" [label="Promote to primary"]
|
||||
"unhealthy_is_healthiest" -> "unhealthy_follow" [label="no" color="red"]
|
||||
"unhealthy_follow" [label="try to follow somebody else()"]
|
||||
"unhealthy_acquire_lock" -> "unhealthy_follow" [label="no" color="red"]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 507 KiB After Width: | Height: | Size: 524 KiB |
@@ -44,7 +44,7 @@ You should not use ``pg_ctl promote`` in this scenario, you need "manually promo
|
||||
|
||||
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.
|
||||
- Add the standby_cluster section back and it will trigger ``pg_rewind``; however, for ``pg_rewind`` to function properly, either the cluster must be initialized with ``data page checksums`` (``--data-checksums`` option for ``initdb``) and/or ``wal_log_hints`` must be set to ``on``, but there are still chances that ``pg_rewind`` might fail due to other factors.
|
||||
- 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.
|
||||
|
||||
+4
-1
@@ -10,7 +10,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
|
||||
|
||||
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 16.
|
||||
Currently supported PostgreSQL versions: 9.3 to 17.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -28,14 +28,17 @@ Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
patronictl
|
||||
replica_bootstrap
|
||||
replication_modes
|
||||
standby_cluster
|
||||
watchdog
|
||||
pause
|
||||
dcs_failsafe_mode
|
||||
kubernetes
|
||||
citus
|
||||
existing_data
|
||||
tools_integration
|
||||
security
|
||||
ha_multi_dc
|
||||
faq
|
||||
releases
|
||||
CONTRIBUTING
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ where ``dependencies`` can be either empty, or consist of one or more of the fol
|
||||
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
|
||||
`py-consul` module in order to use Consul as DCS
|
||||
zookeeper
|
||||
`kazoo` module in order to use Zookeeper as DCS
|
||||
exhibitor
|
||||
@@ -60,6 +60,10 @@ raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto3` in order to use AWS callbacks
|
||||
jsonlogger
|
||||
`python-json-logger` module in order to enable :ref:`logging <log_settings>` in json format
|
||||
systemd
|
||||
`systemd-python` in order to use sd_notify integration
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg
|
||||
|
||||
+7
-7
@@ -37,7 +37,7 @@ Patroni Kubernetes :ref:`settings <kubernetes_settings>` and :ref:`environment v
|
||||
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``.
|
||||
By default, Patroni will set corresponding labels on the pod it runs in based on node's role, such as ``role=primary``.
|
||||
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:
|
||||
@@ -48,8 +48,8 @@ Note that if you migrate from default role labels to custom ones, you can reduce
|
||||
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: master
|
||||
tmp_role: master
|
||||
role: primary
|
||||
tmp_role: primary
|
||||
|
||||
2. After all pods have been updated, modify the service selector to select the temporary label.
|
||||
|
||||
@@ -57,7 +57,7 @@ Note that if you migrate from default role labels to custom ones, you can reduce
|
||||
|
||||
selector:
|
||||
cluster-name: foo
|
||||
tmp_role: master
|
||||
tmp_role: primary
|
||||
|
||||
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:
|
||||
|
||||
@@ -66,7 +66,7 @@ Note that if you migrate from default role labels to custom ones, you can reduce
|
||||
labels:
|
||||
cluster-name: foo
|
||||
role: primary
|
||||
tmp_role: master
|
||||
tmp_role: primary
|
||||
|
||||
4. After all pods have been updated again, modify the service selector to use new role value.
|
||||
|
||||
@@ -87,7 +87,7 @@ Note that if you migrate from default role labels to custom ones, you can reduce
|
||||
Examples
|
||||
--------
|
||||
|
||||
- The `kubernetes <https://github.com/zalando/patroni/tree/master/kubernetes>`__ folder of the Patroni repository contains
|
||||
- The `kubernetes <https://github.com/patroni/patroni/tree/master/kubernetes>`__ folder of the Patroni repository contains
|
||||
examples of the Docker image, and the Kubernetes manifest to test Patroni Kubernetes setup.
|
||||
Note that in the current state it will not be able to use PersistentVolumes because of permission issues.
|
||||
|
||||
@@ -98,5 +98,5 @@ Examples
|
||||
to deploy the Spilo image configured with Patroni running using Kubernetes.
|
||||
|
||||
- In order to run your database clusters at scale using Patroni and Spilo, take a look at the
|
||||
`postgres-operator <https://github.com/zalando-incubator/postgres-operator>`_ project. It implements the operator pattern
|
||||
`postgres-operator <https://github.com/zalando/postgres-operator>`_ project. It implements the operator pattern
|
||||
to manage Spilo clusters.
|
||||
|
||||
@@ -30,6 +30,7 @@ There are 3 types of Patroni configuration:
|
||||
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
|
||||
---------------
|
||||
@@ -48,10 +49,11 @@ Some of the PostgreSQL parameters **must hold the same values on the primary and
|
||||
|
||||
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
|
||||
- **max_wal_senders**: 10
|
||||
- **max_replication_slots**: 10
|
||||
- **wal_keep_segments**: 8
|
||||
- **wal_keep_size**: 128MB
|
||||
- **wal_log_hints**: on
|
||||
|
||||
These parameters are validated to ensure they are sane, or meet a minimum value.
|
||||
|
||||
@@ -61,9 +63,8 @@ There are some other Postgres parameters controlled by Patroni:
|
||||
- **port** - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- **cluster_name** - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
|
||||
- **hot_standby: on**
|
||||
- **wal_log_hints: on** - for Postgres 9.4 and newer.
|
||||
|
||||
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
|
||||
To be on the safe side parameters from the above lists are written into ``postgresql.conf``, and passed as a list of arguments to the ``postgres`` which gives them the highest precedence (except ``wal_keep_segments`` and ``wal_keep_size``), 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.
|
||||
|
||||
@@ -90,6 +91,7 @@ The parameters would be applied in the following order (run-time are given the h
|
||||
|
||||
This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
|
||||
|
||||
.. _shared_memory_gucs:
|
||||
|
||||
PostgreSQL parameters that touch shared memory
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -156,6 +158,7 @@ Patroni provides command-line interfaces for a Patroni :ref:`local 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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -183,6 +186,7 @@ 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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -235,7 +239,7 @@ Validate Patroni configuration
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --validate-config [configfile]
|
||||
patroni --validate-config [configfile] [--ignore-listen-port | -i]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
@@ -247,3 +251,9 @@ 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>`.
|
||||
|
||||
``--ignore-listen-port | -i``
|
||||
Optional flag to ignore bind failures for ``listen`` ports that are already in use when validating the ``configfile``.
|
||||
|
||||
``--print | -p``
|
||||
Optional flag to print out local configuration (including environment configuration overrides) after it has been successfully validated.
|
||||
|
||||
+13
-24
@@ -78,7 +78,7 @@ This is the synopsis for running a command from the ``patronictl``:
|
||||
- Things written in uppercase represent a literal that should be given a value to.
|
||||
|
||||
We will use this same syntax when describing ``patronictl`` sub-commands in the following sub-sections.
|
||||
Also, when describing sub-commands in the following sub-sections, the commands' synposis should be seen as a replacement for the ``SUBCOMMAND`` in the above synopsis.
|
||||
Also, when describing sub-commands in the following sub-sections, the commands' synopsis should be seen as a replacement for the ``SUBCOMMAND`` in the above synopsis.
|
||||
|
||||
In the following sub-sections you can find a description of each command implemented by ``patronictl``. For sake of example, we will use the configuration files present in the GitHub repository of Patroni (files ``postgres0.yml``, ``postgres1.yml`` and ``postgres2.yml``).
|
||||
|
||||
@@ -224,7 +224,7 @@ Parameters
|
||||
|
||||
``PG_CONFIG`` is the name of the Postgres configuration to be set.
|
||||
|
||||
``PG_VALUE`` is the value for ``PG_CONFIG``. If it is ``nulll``, then ``PG_CONFIG`` will be removed from the dynamic configuration.
|
||||
``PG_VALUE`` is the value for ``PG_CONFIG``. If it is ``null``, then ``PG_CONFIG`` will be removed from the dynamic configuration.
|
||||
|
||||
``--apply``
|
||||
Apply dynamic configuration from the given file.
|
||||
@@ -320,7 +320,6 @@ Synopsis
|
||||
failover
|
||||
[ CLUSTER_NAME ]
|
||||
[ --group CITUS_GROUP ]
|
||||
[ { --leader | --primary } LEADER_NAME ]
|
||||
--candidate CANDIDATE_NAME
|
||||
[ --force ]
|
||||
|
||||
@@ -359,16 +358,6 @@ Parameters
|
||||
|
||||
``CITUS_GROUP`` is the ID of the Citus group.
|
||||
|
||||
``--leader`` / ``--primary``
|
||||
Indicate who is the expected leader at failover time.
|
||||
|
||||
If given, a switchover is performed instead of a failover.
|
||||
|
||||
``LEADER_NAME`` should match the name of the current leader in the cluster.
|
||||
|
||||
.. warning::
|
||||
This argument is deprecated and will be removed in a future release.
|
||||
|
||||
``--candidate``
|
||||
The node to be promoted on failover.
|
||||
|
||||
@@ -1065,7 +1054,7 @@ Run a SQL command as ``postgres`` user, and take password from ``libpq`` environ
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ PGPASSWORD=zalando patronictl -c postgres0.yml query batman -U postgres -c "SELECT now()"
|
||||
$ PGPASSWORD=patroni patronictl -c postgres0.yml query batman -U postgres -c "SELECT now()"
|
||||
now
|
||||
2023-09-12 18:11:37.639500+00:00
|
||||
|
||||
@@ -1438,7 +1427,7 @@ Parameters
|
||||
``--scheduled``
|
||||
Schedule a restart to occur at the given timestamp.
|
||||
|
||||
``TIMESTAMP`` is the timestamp when the restart should occur. Specify it in unambiguous format, preferrably with time zone. You can also use the literal ``now`` for the restart to be executed immediately.
|
||||
``TIMESTAMP`` is the timestamp when the restart should occur. Specify it in unambiguous format, preferably with time zone. You can also use the literal ``now`` for the restart to be executed immediately.
|
||||
|
||||
``--force``
|
||||
Flag to skip confirmation prompts when requesting the restart operations.
|
||||
@@ -1676,7 +1665,7 @@ Parameters
|
||||
``--scheduled``
|
||||
Schedule a switchover to occur at the given timestamp.
|
||||
|
||||
``TIMESTAMP`` is the timestamp when the switchover should occur. Specify it in unambiguous format, preferrably with time zone. You can also use the literal ``now`` for the switchover to be executed immediately.
|
||||
``TIMESTAMP`` is the timestamp when the switchover should occur. Specify it in unambiguous format, preferably with time zone. You can also use the literal ``now`` for the switchover to be executed immediately.
|
||||
|
||||
``--force``
|
||||
Flag to skip confirmation prompts when performing the switchover.
|
||||
@@ -1951,25 +1940,25 @@ Get version of ``patronictl`` only:
|
||||
.. code:: bash
|
||||
|
||||
$ patronictl -c postgres0.yml version
|
||||
patronictl version 3.1.0
|
||||
patronictl version 4.0.0
|
||||
|
||||
Get version of ``patronictl`` and of all members of cluster ``batman``:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ patronictl -c postgres0.yml version batman
|
||||
patronictl version 3.1.0
|
||||
patronictl version 4.0.0
|
||||
|
||||
postgresql0: Patroni 3.1.0 PostgreSQL 15.2
|
||||
postgresql1: Patroni 3.1.0 PostgreSQL 15.2
|
||||
postgresql2: Patroni 3.1.0 PostgreSQL 15.2
|
||||
postgresql0: Patroni 4.0.0 PostgreSQL 16.4
|
||||
postgresql1: Patroni 4.0.0 PostgreSQL 16.4
|
||||
postgresql2: Patroni 4.0.0 PostgreSQL 16.4
|
||||
|
||||
Get version of ``patronictl`` and of members ``postgresql1`` and ``postgresql2`` of cluster ``batman``:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ patronictl -c postgres0.yml version batman postgresql1 postgresql2
|
||||
patronictl version 3.1.0
|
||||
patronictl version 4.0.0
|
||||
|
||||
postgresql1: Patroni 3.1.0 PostgreSQL 15.2
|
||||
postgresql2: Patroni 3.1.0 PostgreSQL 15.2
|
||||
postgresql1: Patroni 4.0.0 PostgreSQL 16.4
|
||||
postgresql2: Patroni 4.0.0 PostgreSQL 16.4
|
||||
|
||||
+596
-12
@@ -3,9 +3,491 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 4.0.5
|
||||
-------------
|
||||
|
||||
Released 2025-02-20
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Compatibility with ``python-json-logger>=3.1`` (Alexander Kukushkin)
|
||||
|
||||
Get rid of the warnings produced by the old API usage.
|
||||
|
||||
- Compatibility with Python 3.13 (Alexander Kukushkin)
|
||||
|
||||
Run tests against Python 3.13.
|
||||
|
||||
- Compatibility with ``pyinstaller>=4.4`` (Joe Jensen)
|
||||
|
||||
Fall back to the default ``iter_modules`` if ``pyinstaller`` ``toc`` attribute is not present.
|
||||
|
||||
- Fix issues with PostgreSQL 9.5 support (Alexander Kukushkin)
|
||||
|
||||
- Properly handle ``pg_rewind`` output format.
|
||||
- Consider ``synchronous_standby_names`` format not supporting "num" specification.
|
||||
|
||||
- Compatibility with the latest changes in ``urlparse`` (Alexander Kukushkin)
|
||||
|
||||
``urlparse`` doesn't accept multiple hosts with ``[]`` character in URL anymore. To mitigate the problem, switch to the native wrappers of ``PQconninfoParse()`` from ``libpq``, when it is possible, and use our implementation only for older ``psycopg2`` versions that are linked with an outdated version of ``libpq``.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Show only the members to be restarted upon restart confirmation (András Váczi)
|
||||
|
||||
Previously, when doing ``patronictl restart <clustername> --pending``, the confirmation listed all members, regardless of whether their restart is pending.
|
||||
|
||||
- Cancel long-running jobs on Patroni stop and remove data directory on replica bootstrap failure (Alexander Kukushkin)
|
||||
|
||||
Previously, Patroni could be doing replica bootstrap, while ``pg_basebackup`` / ``wal-g`` / ``pgBackRest`` / ``barman`` or similar keep running.
|
||||
|
||||
- Properly handle cluster names with a slash in ``patronictl edit-config`` (Antoni Mur)
|
||||
|
||||
Replace a forward slash in ``cluster_name`` with an underscore.
|
||||
|
||||
- Avoid dropping physical slots too early (Alexander Kukushkin)
|
||||
|
||||
Postpone removal of physical replication slots containing ``xmin`` after a failover: on the new primary -- until this member is promoted, on replicas -- until there is a leader in the cluster.
|
||||
|
||||
- Handle all exceptions raised by subprocess in ``controldata()`` (Alexander Kukushkin)
|
||||
|
||||
Patroni was not properly handling all exceptions possibly raised when calling ``pg_controldata`` utility.
|
||||
|
||||
- Fix bug with a slot for a former leader not retained on failover (Alexander Kukushkin)
|
||||
|
||||
Avoid falsely relying on members being present in DCS, while on failover ``/member`` key for the former leader is expiring exactly at the same time.
|
||||
|
||||
- Fix a couple of bugs in the quorum state machine (Alexander Kukushkin)
|
||||
|
||||
- When evaluating whether there are healthy nodes for a leader race, before demoting we need to take into account quorum requirements. Without it, the former leader may end up in recovery surrounded by asynchronous nodes.
|
||||
- ``QuorumStateResolver`` wasn't correctly handling the case when a replica node quickly joined and disconnected.
|
||||
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Improve error on am empty or non-dictionary configuration file (Julian)
|
||||
|
||||
Throw a more explicit exception when validating if Patroni configuration file contains a valid ``Mapping`` object.
|
||||
|
||||
|
||||
Version 4.0.4
|
||||
-------------
|
||||
|
||||
Released 2024-11-22
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Add compatibility with the ``py-consul`` module (Alexander Kukushkin)
|
||||
|
||||
``python-consul`` module is unmaintained for a long time, while ``py-consul`` is the official replacement. Backward compatibility with python-consul is retained.
|
||||
|
||||
- Add compatibility with the ``prettytable>=3.12.0`` module (Alexander Kukushkin)
|
||||
|
||||
Address deprecation warnings.
|
||||
|
||||
- Compatibility with the ``ydiff==1.4.2`` module (Alexander Kukushkin)
|
||||
|
||||
Fix compatibility issues for the latest version, constrain version in ``requirements.txt``, and introduce latest version compatibility test.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Run ``on_role_change`` callback after a failed primary recovery (Polina Bungina, Alexander Kukushkin)
|
||||
|
||||
Additionally run ``on_role_change`` callback for a primary that failed to start after a crash to increase chances the callback is executed, even if the further start as a replica fails.
|
||||
|
||||
- Fix a thread leak in ``patronictl list -W`` (Alexander Kukushkin)
|
||||
|
||||
Cache DCS instance object to avoid thread leak.
|
||||
|
||||
- Ensure only supported parameters are written to the connection string (Alexander Kukushkin)
|
||||
|
||||
Patroni used to pass parameters introduced in newer versions to the connection string, which had been leading to connection errors.
|
||||
|
||||
|
||||
Version 4.0.3
|
||||
-------------
|
||||
|
||||
Released 2024-10-18
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Disable ``pgaudit`` when creating users not to expose password (kviset)
|
||||
|
||||
Patroni was logging ``superuser``, ``replication``, and ``rewind`` passwords on their creation when ``pgaudit`` extension was enabled.
|
||||
|
||||
- Fix issue with mixed setups: primary on pre-Patroni v4 and replicas on v4+ (Alexander Kukushkin)
|
||||
|
||||
Use ``xlog_location`` extracted from ``/members`` key instead of trying to get a member's slot position from ``/status`` key if Patroni version running on the leader is pre-4.0.0. Not doing so has been causing WALs accumulation on replicas.
|
||||
|
||||
- Do not ignore valid PostgreSQL GUCs that don't have Patroni validator (Polina Bungina)
|
||||
|
||||
Still check against ``postgres --describe-config`` if a GUC does not have a Patroni validator but is, in fact, a valid GUC.
|
||||
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Recheck annotations on 409 status code when reading leader object in K8s (Alexander Kukushkin)
|
||||
|
||||
Avoid an additional update if ``PATCH`` request was canceled by Patroni, while the request successfully updated the target.
|
||||
|
||||
- Add support of ``sslnegotiation`` client-side connection option (Alexander Kukushkin)
|
||||
|
||||
``sslnegotiation`` was added to the final PostgreSQL 17 release.
|
||||
|
||||
|
||||
Version 4.0.2
|
||||
-------------
|
||||
|
||||
Released 2024-09-17
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Handle exceptions while discovering configuration validation files (Alexander Kukushkin)
|
||||
|
||||
Skip directories for which Patroni does not have sufficient permissions to perform list operations.
|
||||
|
||||
- Make sure inactive hot physical replication slots don't hold ``xmin`` (Alexander Kukushkin, Polina Bungina)
|
||||
|
||||
Since version 3.2.0 Patroni creates physical replication slots for all members on replicas and periodically moves them forward using ``pg_replication_slot_advance()`` function. However if for any reason ``hot_standby_feedback`` is enabled and the primary is demoted to replica, the now inactive slots have ``NOT NULL`` ``xmin`` value propagated back to the new primary. This results in ``xmin`` horizon not being moved forward and vacuum not being able to clean up dead tuples. With this fix, Patroni recreates the physical replication slots that are supposed to be inactive but have ``NOT NULL`` ``xmin`` value.
|
||||
|
||||
- Fix unhandled ``DCSError`` during the startup phase (Waynerv)
|
||||
|
||||
Ensure DCS connectivity before trying to check the uniqueness of the node name.
|
||||
|
||||
- Explicitly include ``CMDLINE_OPTIONS`` GUCs when querying ``pg_settings`` (Alexander Kukushkin)
|
||||
|
||||
Make sure all GUCs that are passed to postmaster as command line parameters are restored when Patroni is joining a running standby. This is a follow-up for the bug fixed in Patroni 3.2.2.
|
||||
|
||||
- Fix bug in ``synchronous_standby_names`` quotting logic (Alexander Kukushkin)
|
||||
|
||||
According to PostgreSQL documentation, ``ANY`` and ``FIRST`` keywords are supposed to be double-quoted, which Patroni did not do before.
|
||||
|
||||
- Fix keepalive connection out-of-range issue (hadizamani021)
|
||||
|
||||
Ensure that ``keepalive`` option value calculated based on the ``ttl`` set does not exceed the maximum allowed value for the current platform.
|
||||
|
||||
|
||||
Version 4.0.1
|
||||
-------------
|
||||
|
||||
Released 2024-08-30
|
||||
|
||||
**Bugfix**
|
||||
|
||||
- Patroni was creating unnecessary replication slots for itself (Alexander Kukushkin)
|
||||
|
||||
It was happening if ``name`` contains upper-case or special characters.
|
||||
|
||||
|
||||
Version 4.0.0
|
||||
-------------
|
||||
|
||||
Released 2024-08-29
|
||||
|
||||
.. warning::
|
||||
- This version completes work on getting rid of the "master" term, in favor of "primary". This means a couple of breaking changes, please read the release notes carefully. Upgrading to the Patroni 4+ will work reliably only if you run Patroni 3.1.0 or newer. Upgrading from an older version directly to 4+ is possible but may lead to unexpected behavior if the primary fails while the rest of the nodes are running on other Patroni versions.
|
||||
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
- The following breaking changes were introduced when getting rid of the non-inclusive "master" term in the Patroni code:
|
||||
|
||||
- On Kubernetes, Patroni by default will set ``role`` label to ``primary``. In case if you want to keep the old behavior and avoid downtime or lengthy complex migrations, you can configure parameters ``kubernetes.leader_label_value`` and ``kubernetes.standby_leader_label_value`` to ``master``. Read more :ref:`here <kubernetes_role_values>`.
|
||||
- Patroni role is written to DCS as ``primary`` instead of ``master``.
|
||||
- Patroni role returned by Patroni REST API has been changed from ``master`` to ``primary``.
|
||||
- Patroni REST API no longer accepts ``role=master`` in requests to ``/switchover``, ``/failover``, ``/restart`` endpoints.
|
||||
- ``/metrics`` REST API endpoint will no longer report ``patroni_master`` metric.
|
||||
- ``patronictl`` no longer accepts ``--master`` option for any command. ``--leader`` or ``--primary`` options should be used instead.
|
||||
- ``no_master`` option in the declarative configuration of custom replica creation methods is no longer treated as a special option, please use ``no_leader`` instead.
|
||||
- ``patroni_wale_restore`` script doesn't accept ``--no_master`` option anymore.
|
||||
- ``patroni_barman`` script doesn't accept ``--role=master`` option anymore.
|
||||
- All callback scripts are executed with ``role=primary`` option passed instead of ``role=master``.
|
||||
|
||||
- ``patronictl failover`` does not accept ``--leader`` option that was deprecated since Patroni 3.2.0.
|
||||
|
||||
- User creation functionality (``bootstrap.users`` configuration section) deprecated since Patroni 3.2.0 has been removed.
|
||||
|
||||
|
||||
**New features**
|
||||
|
||||
- Quorum-based failover (Ants Aasma, Alexander Kukushkin)
|
||||
|
||||
The feature implements quorum-based synchronous replication (available from PostgreSQL v10) which helps to reduce worst-case latencies, even during normal operation, as a higher latency of replicating to one standby can be compensated by other standbys. Patroni implements additional safeguards to prevent any user-visible data loss by choosing a failover candidate based on the latest transaction received.
|
||||
|
||||
- Register Citus secondaries in ``pg_dist_node`` (Alexander Kukushkin)
|
||||
|
||||
Patroni now maintains the list of nodes with ``role==replica``, ``state==running`` and without ``noloadbalance`` :ref:`tag <tags_settings>` in ``pg_dist_node``.
|
||||
|
||||
- Configurable retention of members' replication slots (Alexander Kukushkin)
|
||||
|
||||
Implements support of ``member_slots_ttl`` global configuration parameter that controls for how long member replication slots should be kept around when the member key is absent.
|
||||
|
||||
- Make permissions of log files created by Patroni configurable (Alexander Kukushkin)
|
||||
|
||||
Allows to set specific permissions for log files created by Patroni. If not specified, permissions are set based on the current ``umask`` value.
|
||||
|
||||
- Compatibility with PostgreSQL 17 beta3 (Alexander Kukushkin)
|
||||
|
||||
GUC's validator rules were extended. Patroni handles all the new auxiliary backends during shutdown and sets ``dbname`` in ``primary_conninfo``, as it is required for logical replication slots synchronization.
|
||||
|
||||
- Implement ``--ignore-listen-port`` option for Patroni config validation (Sahil Naphade)
|
||||
|
||||
Make it possible to ignore already bound ports when running ``patroni --validate-config``.
|
||||
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Make ``wal_log_hints`` configurable (Paul_Kim)
|
||||
|
||||
Allows to avoid the overhead of ``wal_log_hints`` configuration being enabled in case ``use_pg_rewind`` is set to ``off``.
|
||||
|
||||
- Log ``pg_basebackup`` command in ``DEBUG`` level (Waynerv)
|
||||
|
||||
Facilitates failed initialization debugging.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Advance permanent slots for cascading nodes while in failsafe (Alexander Kukushkin)
|
||||
|
||||
Ensure that slots for cascading replicas are properly advanced on the primary when failsafe mode is activated. It is done by extending replicas response on ``POST /failsafe`` REST API request with their ``xlog_location``.
|
||||
|
||||
- Don't let the current node be chosen as synchronous (Alexander Kukushkin)
|
||||
|
||||
There may be "something" streaming from the current primary node with ``application_name`` that matches the name of the current primary. Patroni was not properly handling this situation, which could end up in the primary being declared as a synchronous node and consequently was blocking switchovers.
|
||||
|
||||
- Ignore ``restapi.allowlist_include_members`` for POST /failsafe (Alexander Kukushkin)
|
||||
|
||||
- Improve GUCs validation (Polina Bungina)
|
||||
|
||||
Due to additional validation through running ``postgres --describe-config`` command, it was previously not possible to set GUCs not listed there through Patroni configuration. This limitation is now removed.
|
||||
|
||||
- Add line with ``localhost`` to ``.pgpass`` file when unix sockets are detected (Alexander Kukushkin)
|
||||
|
||||
Patroni will add an additional line to ``.pgpass`` file if ``host`` parameter specified starts with ``/`` character. This allows to cover a corner case when ``host`` matches the default socket directory path.
|
||||
|
||||
- Fix logging issues (Waynerv)
|
||||
|
||||
Defined proper request URL in failsafe handling logs and fixed the order of timestamps in postmaster check log.
|
||||
|
||||
|
||||
Version 3.3.2
|
||||
-------------
|
||||
|
||||
Released 2024-07-11
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fix plain Postgres synchronous replication mode (Israel Barth Rubio)
|
||||
|
||||
Since ``synchronous_mode`` was introduced to Patroni, the plain Postgres synchronous replication was not working. With this bugfix, Patroni sets the value of ``synchronous_standby_names`` as configured by the user, if that is the case, when ``synchronous_mode`` is disabled.
|
||||
|
||||
- Handle logical slots invalidation on a standby (Polina Bungina)
|
||||
|
||||
Since PG16 logical replication slots on a standby can be invalidated due to horizon: from now on, Patroni forces copy (i.e., recreation) of invalidated slots.
|
||||
|
||||
- Fix race condition with logical slot advance and copy (Alexander Kukushkin)
|
||||
|
||||
Due to this bug, it was a possible situation when an invalidated logical replication slot was copied with PostgreSQL restart more than once.
|
||||
|
||||
|
||||
Version 3.3.1
|
||||
-------------
|
||||
|
||||
Released 2024-06-17
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Compatibility with Python 3.12 (Alexander Kukushkin)
|
||||
|
||||
Handle a new attribute added to ``logging.LogRecord``.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fix infinite recursion in ``replicatefrom`` tags handling (Alexander Kukushkin)
|
||||
|
||||
As a part of this fix, also improve ``is_physical_slot()`` check and adjust documentation.
|
||||
|
||||
- Fix wrong role reporting in standby clusters (Alexander Kukushkin)
|
||||
|
||||
``synchronous_standby_names`` and synchronous replication only work on a real primary node and in the case of cascading replication are simply ignored by Postgres. Before this fix, ``patronictl list`` and ``GET /cluster`` were falsely reporting some nodes as synchronous.
|
||||
|
||||
- Fix availability of the ``allow_in_place_tablespaces`` GUC (Polina Bungina)
|
||||
|
||||
``allow_in_place_tablespaces`` was not only added to PostgreSQL 15 but also backpatched to PostgreSQL 10-14.
|
||||
|
||||
|
||||
Version 3.3.0
|
||||
-------------
|
||||
|
||||
Released 2024-04-04
|
||||
|
||||
.. warning::
|
||||
All older Partoni versions are not compatible with ``ydiff>=1.3``.
|
||||
|
||||
There are the following options available to "fix" the problem:
|
||||
|
||||
1. upgrade Patroni to the latest version
|
||||
2. install ``ydiff<1.3`` after installing Patroni
|
||||
3. install ``cdiff`` module
|
||||
|
||||
|
||||
**New features**
|
||||
|
||||
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
|
||||
|
||||
It allows to specify the authentication credentials to use for the connection.
|
||||
|
||||
- Add a contrib script for ``Barman`` integration (Israel Barth Rubio)
|
||||
|
||||
Provide an application ``patroni_barman`` that allows to perform ``Barman`` operations remotely and can be used as a custom bootstrap/custom replica method or as an ``on_role_change`` callback. Please check :ref:`here <tools_integration>` for more information.
|
||||
|
||||
- Support ``JSON`` log format (alisalemmi)
|
||||
|
||||
Apart from ``plain`` (default), Patroni now also supports ``json`` log format. Requires ``python-json-logger>=2.0.2`` library to be installed.
|
||||
|
||||
- Show ``pending_restart_reason`` information (Polina Bungina)
|
||||
|
||||
Provide extended information about the PostgreSQL parameters that caused ``pending_restart`` flag to be set. Both ``patronictl list`` and ``/patroni`` REST API endpoint now show the parameters names and their "diff" as ``pending_restart_reason``.
|
||||
|
||||
- Implement ``nostream`` tag (Grigory Smolkin)
|
||||
|
||||
If ``nostream`` tag is set to ``true``, the node will not use replication protocol to stream WAL but instead rely on archive recovery (if ``restore_command`` is configured). It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas.
|
||||
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Implement validation of the ``log`` section (Alexander Kukushkin)
|
||||
|
||||
Until now validator was not checking the correctness of the logging configuration provided.
|
||||
|
||||
- Improve logging for PostgreSQL parameters change (Polina Bungina)
|
||||
|
||||
Convert old values to a human-readable format and log information about the ``pg_controldata`` vs Patroni global configuration mismatch.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Properly filter out not allowed ``pg_basebackup`` options (Israel Barth Rubio)
|
||||
|
||||
Due to a bug, Patroni was not properly filtering out the not allowed options configured for the ``basebackup`` replica bootstrap method, when provided in the ``- setting: value`` format.
|
||||
|
||||
- Fix ``etcd3`` authentication error handling (Alexander Kukushkin)
|
||||
|
||||
Always retry one time on ``etcd3`` authentication error if authentication was not done right before executing the request. Also, do not restart watchers on reauthentication.
|
||||
|
||||
- Improve logic of the validator files discovery (Waynerv)
|
||||
|
||||
Use ``importlib`` library to discover the files with available configuration parameters when possible (for Python 3.9+). This implementation is more stable and doesn't break the Patroni distributions based on ``zip`` archives.
|
||||
|
||||
- Use ``target_session_attrs`` only when multiple hosts are specified in the ``standby_cluster`` section (Alexander Kukushkin)
|
||||
|
||||
``target_session_attrs=read-write`` is now added to the ``primary_conninfo`` on the standby leader node only when ``standby_cluster.host`` section contains multiple hosts separated by commas.
|
||||
|
||||
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
|
||||
|
||||
Patroni is relying on some API from ``ydiff`` that is not public because it is supposed to be just a terminal tool rather than a python module. Unfortunately, the API change in 1.3 broke old Patroni versions.
|
||||
|
||||
|
||||
Version 3.2.2
|
||||
-------------
|
||||
|
||||
Released 2024-01-17
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Don't let replica restore initialize key when DCS was wiped (Alexander Kukushkin)
|
||||
|
||||
It was happening in the method where Patroni was supposed to take over a standalone PG cluster.
|
||||
|
||||
- Use consistent read when fetching just updated sync key from Consul (Alexander Kukushkin)
|
||||
|
||||
Consul doesn't provide any interface to immediately get ``ModifyIndex`` for the key that we just updated, therefore we have to perform an explicit read operation. Since stale reads are allowed by default, we sometimes used to get an outdated version of the key.
|
||||
|
||||
- Reload Postgres config if a parameter that requires restart was reset to the original value (Polina Bungina)
|
||||
|
||||
Previously Patroni wasn't updating the config, but only resetting the ``pending_restart``.
|
||||
|
||||
- Fix erroneous inverted logic of the confirmation prompt message when doing a failover to an async candidate in synchronous mode (Polina Bungina)
|
||||
|
||||
The problem existed only in ``patronictl``.
|
||||
|
||||
- Exclude leader from failover candidates in ``patronictl`` (Polina Bungina)
|
||||
|
||||
If the cluster is healthy, failing over to an existing leader is no-op.
|
||||
|
||||
- Create Citus database and extension idempotently (Alexander Kukushkin, Zhao Junwang)
|
||||
|
||||
It will allow to create them in the ``post_bootstrap`` script in case if there is a need to add some more dependencies to the Citus database.
|
||||
|
||||
- Don't filter our contradictory ``nofailover`` tag (Polina Bungina)
|
||||
|
||||
The configuration ``{nofailover: false, failover_priority: 0}`` set on a node didn't allow it to participate in the race, while it should, because ``nofailover`` tag should take precedence.
|
||||
|
||||
- Fixed PyInstaller frozen issue (Sophia Ruan)
|
||||
|
||||
The ``freeze_support()`` was called after ``argparse`` and as a result, Patroni wasn't able to start Postgres.
|
||||
|
||||
- Fixed bug in the config generator for ``patronictl`` and ``Citus`` configuration (Israel Barth Rubio)
|
||||
|
||||
It prevented ``patronictl`` and ``Citus`` configuration parameters set via environment variables from being written into the generated config.
|
||||
|
||||
- Restore recovery GUCs and some Patroni-managed parameters when joining a running standby (Alexander Kukushkin)
|
||||
|
||||
Patroni was failing to restart Postgres v12 onwards with an error about missing ``port`` in one of the internal structures.
|
||||
|
||||
- Fixes around ``pending_restart`` flag (Polina Bungina)
|
||||
|
||||
Don't expose ``pending_restart`` when in custom bootstrap with ``recovery_target_action = promote`` or when someone changed ``hot_standby`` or ``wal_log_hints`` using for example ``ALTER SYSTEM``.
|
||||
|
||||
|
||||
Version 3.2.1
|
||||
-------------
|
||||
|
||||
Released 2023-11-30
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Limit accepted values for ``--format`` argument in ``patronictl`` (Alexander Kukushkin)
|
||||
|
||||
It used to accept any arbitrary string and produce no output if the value wasn't recognized.
|
||||
|
||||
- Verify that replica nodes received checkpoint LSN on shutdown before releasing the leader key (Alexander Kukushkin)
|
||||
|
||||
Previously in some cases, we were using LSN of the SWITCH record that is followed by CHECKPOINT (if archiving mode is enabled). As a result the former primary sometimes had to do ``pg_rewind``, but there would be no data loss involved.
|
||||
|
||||
- Do a real HTTP request when performing node name uniqueness check (Alexander Kukushkin)
|
||||
|
||||
When running Patroni in containers it is possible that the traffic is routed using ``docker-proxy``, which listens on the port and accepts incoming connections. It was causing false positives.
|
||||
|
||||
- Fixed Citus support with Etcd v2 (Alexander Kukushkin)
|
||||
|
||||
Patroni was failing to deploy a new Citus cluster with Etcd v2.
|
||||
|
||||
- Fixed ``pg_rewind`` behavior with Postgres v16+ (Alexander Kukushkin)
|
||||
|
||||
The error message format of ``pg_waldump`` changed in v16 which caused ``pg_rewind`` to be called by Patroni even when it was not necessary.
|
||||
|
||||
- Fixed bug with custom bootstrap (Alexander Kukushkin)
|
||||
|
||||
Patroni was falsely applying ``--command`` argument, which is a bootstrap command itself.
|
||||
|
||||
- Fixed the issue with REST API health check endpoints (Sophia Ruan)
|
||||
|
||||
There were chances that after Postgres restart it could return ``unknown`` state for Postgres because connections were not properly closed.
|
||||
|
||||
- Cache ``postgres --describe-config`` output results (Waynerv)
|
||||
|
||||
They are used to figure out which GUCs are available to validate PostgreSQL configuration and we don't expect this list to change while Patroni is running.
|
||||
|
||||
|
||||
Version 3.2.0
|
||||
-------------
|
||||
|
||||
Released 2023-10-25
|
||||
|
||||
**Deprecation notice**
|
||||
|
||||
- The ``bootstrap.users`` support will be removed in version 4.0.0. If you need to create users after deploying a new cluster please use the ``bootstrap.post_bootstrap`` hook for that.
|
||||
@@ -78,6 +560,8 @@ Version 3.2.0
|
||||
Version 3.1.2
|
||||
-------------
|
||||
|
||||
Released 2023-09-26
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed bug with ``wal_keep_size`` checks (Alexander Kukushkin)
|
||||
@@ -100,6 +584,8 @@ Version 3.1.2
|
||||
Version 3.1.1
|
||||
-------------
|
||||
|
||||
Released 2023-09-20
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Reset failsafe state on promote (ChenChangAo)
|
||||
@@ -152,12 +638,14 @@ Version 3.1.1
|
||||
|
||||
- Don't rely on ``pg_stat_wal_receiver`` when deciding on ``pg_rewind`` (Alexander Kukushkin)
|
||||
|
||||
It could happen that ``received_tli`` reported by ``pg_stat_wal_recevier`` is ahead of the actual replayed timeline, while the timeline reported by ``DENTIFY_SYSTEM`` via replication connection is always correct.
|
||||
It could happen that ``received_tli`` reported by ``pg_stat_wal_receiver`` is ahead of the actual replayed timeline, while the timeline reported by ``DENTIFY_SYSTEM`` via replication connection is always correct.
|
||||
|
||||
|
||||
Version 3.1.0
|
||||
-------------
|
||||
|
||||
Released 2023-08-03
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
- Changed semantic of ``restapi.keyfile`` and ``restapi.certfile`` (Alexander Kukushkin)
|
||||
@@ -236,6 +724,8 @@ Version 3.1.0
|
||||
Version 3.0.4
|
||||
-------------
|
||||
|
||||
Released 2023-07-13
|
||||
|
||||
**New features**
|
||||
|
||||
- Make the replication status of standby nodes visible (Alexander Kukushkin)
|
||||
@@ -280,6 +770,8 @@ Version 3.0.4
|
||||
Version 3.0.3
|
||||
-------------
|
||||
|
||||
Released 2023-06-22
|
||||
|
||||
**New features**
|
||||
|
||||
- Compatibility with PostgreSQL 16 beta1 (Alexander Kukushkin)
|
||||
@@ -334,6 +826,8 @@ Version 3.0.3
|
||||
Version 3.0.2
|
||||
-------------
|
||||
|
||||
Released 2023-03-24
|
||||
|
||||
.. warning::
|
||||
Version 3.0.2 dropped support of Python older than 3.6.
|
||||
|
||||
@@ -390,6 +884,8 @@ Version 3.0.2
|
||||
Version 3.0.1
|
||||
-------------
|
||||
|
||||
Released 2023-02-16
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Pass proper role name to an ``on_role_change`` callback script'. (Alexander Kukushkin, Polina Bungina)
|
||||
@@ -400,6 +896,8 @@ Version 3.0.1
|
||||
Version 3.0.0
|
||||
-------------
|
||||
|
||||
Released 2023-01-30
|
||||
|
||||
This version adds integration with `Citus <https://www.citusdata.com>`__ and makes it possible to survive temporary DCS outages without demoting primary.
|
||||
|
||||
.. warning::
|
||||
@@ -450,6 +948,8 @@ This version adds integration with `Citus <https://www.citusdata.com>`__ and mak
|
||||
Version 2.1.7
|
||||
-------------
|
||||
|
||||
Released 2023-01-04
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed little incompatibilities with legacy python modules (Alexander Kukushkin)
|
||||
@@ -460,6 +960,8 @@ Version 2.1.7
|
||||
Version 2.1.6
|
||||
-------------
|
||||
|
||||
Released 2022-12-30
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Fix annoying exceptions on ssl socket shutdown (Alexander Kukushkin)
|
||||
@@ -515,6 +1017,8 @@ Version 2.1.6
|
||||
Version 2.1.5
|
||||
-------------
|
||||
|
||||
Released 2022-11-28
|
||||
|
||||
This version enhances compatibility with PostgreSQL 15 and declares Etcd v3 support as production ready. The Patroni on Raft remains in Beta.
|
||||
|
||||
**New features**
|
||||
@@ -613,6 +1117,8 @@ This version enhances compatibility with PostgreSQL 15 and declares Etcd v3 supp
|
||||
Version 2.1.4
|
||||
-------------
|
||||
|
||||
Released 2022-06-01
|
||||
|
||||
**New features**
|
||||
|
||||
- Improve ``pg_rewind`` behavior on typical Debian/Ubuntu systems (Gunnar "Nick" Bluth)
|
||||
@@ -685,6 +1191,8 @@ Version 2.1.4
|
||||
Version 2.1.3
|
||||
-------------
|
||||
|
||||
Released 2022-02-18
|
||||
|
||||
**New features**
|
||||
|
||||
- Added support for encrypted TLS keys for ``patronictl`` (Alexander Kukushkin)
|
||||
@@ -751,6 +1259,8 @@ Version 2.1.3
|
||||
Version 2.1.2
|
||||
-------------
|
||||
|
||||
Released 2021-12-03
|
||||
|
||||
**New features**
|
||||
|
||||
- Compatibility with ``psycopg>=3.0`` (Alexander Kukushkin)
|
||||
@@ -855,6 +1365,8 @@ Version 2.1.2
|
||||
Version 2.1.1
|
||||
-------------
|
||||
|
||||
Released 2021-08-19
|
||||
|
||||
**New features**
|
||||
|
||||
- Support for ETCD SRV name suffix (David Pavlicek)
|
||||
@@ -891,6 +1403,8 @@ Version 2.1.1
|
||||
Version 2.1.0
|
||||
-------------
|
||||
|
||||
Released 2021-07-06
|
||||
|
||||
This version adds compatibility with PostgreSQL v14, makes logical replication slots to survive failover/switchover, implements support of allowlist for REST API, and also reducing the number of logs to one line per heart-beat.
|
||||
|
||||
**New features**
|
||||
@@ -983,6 +1497,8 @@ This version adds compatibility with PostgreSQL v14, makes logical replication s
|
||||
Version 2.0.2
|
||||
-------------
|
||||
|
||||
Released 2021-02-22
|
||||
|
||||
**New features**
|
||||
|
||||
- Ability to ignore externally managed replication slots (James Coleman)
|
||||
@@ -1079,6 +1595,8 @@ Version 2.0.2
|
||||
Version 2.0.1
|
||||
-------------
|
||||
|
||||
Released 2020-10-01
|
||||
|
||||
**New features**
|
||||
|
||||
- Use ``more`` as pager in ``patronictl edit-config`` if ``less`` is not available (Pavel Golub)
|
||||
@@ -1127,6 +1645,8 @@ Version 2.0.1
|
||||
Version 2.0.0
|
||||
-------------
|
||||
|
||||
Released 2020-09-02
|
||||
|
||||
This version enhances compatibility with PostgreSQL 13, adds support of multiple synchronous standbys, has significant improvements in handling of ``pg_rewind``, adds support of Etcd v3 and Patroni on pure RAFT (without Etcd, Consul, or Zookeeper), and makes it possible to optionally call the ``pre_promote`` (fencing) script.
|
||||
|
||||
**PostgreSQL 13 support**
|
||||
@@ -1178,7 +1698,7 @@ This version enhances compatibility with PostgreSQL 13, adds support of multiple
|
||||
|
||||
Replicas are waiting for checkpoint indication via member key of the leader in DCS. The key is normally updated only once per HA loop. Without waking the main thread up, replicas will have to wait up to ``loop_wait`` seconds longer than necessary.
|
||||
|
||||
- Use of ``pg_stat_wal_recevier`` view on 9.6+ (Alexander Kukushkin)
|
||||
- Use of ``pg_stat_wal_receiver`` view on 9.6+ (Alexander Kukushkin)
|
||||
|
||||
The view contains up-to-date values of ``primary_conninfo`` and ``primary_slot_name``, while the contents of ``recovery.conf`` could be stale.
|
||||
|
||||
@@ -1349,6 +1869,8 @@ This version enhances compatibility with PostgreSQL 13, adds support of multiple
|
||||
Version 1.6.5
|
||||
-------------
|
||||
|
||||
Released 2020-08-23
|
||||
|
||||
**New features**
|
||||
|
||||
- Master stop timeout (Krishna Sarabu)
|
||||
@@ -1465,6 +1987,8 @@ Version 1.6.5
|
||||
Version 1.6.4
|
||||
-------------
|
||||
|
||||
Released 2020-01-27
|
||||
|
||||
**New features**
|
||||
|
||||
- Implemented ``--wait`` option for ``patronictl reinit`` (Igor Yanchenko)
|
||||
@@ -1527,11 +2051,13 @@ Version 1.6.4
|
||||
Version 1.6.3
|
||||
-------------
|
||||
|
||||
Released 2019-12-05
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Don't expose password when running ``pg_rewind`` (Alexander Kukushkin)
|
||||
|
||||
Bug was introduced in the `#1301 <https://github.com/zalando/patroni/pull/1301>`__
|
||||
Bug was introduced in the `#1301 <https://github.com/patroni/patroni/pull/1301>`__
|
||||
|
||||
- Apply connection parameters specified in the ``postgresql.authentication`` to ``pg_basebackup`` and custom replica creation methods (Alexander Kukushkin)
|
||||
|
||||
@@ -1541,6 +2067,8 @@ Version 1.6.3
|
||||
Version 1.6.2
|
||||
-------------
|
||||
|
||||
Released 2019-12-05
|
||||
|
||||
**New features**
|
||||
|
||||
- Implemented ``patroni --version`` (Igor Yanchenko)
|
||||
@@ -1596,6 +2124,8 @@ Version 1.6.2
|
||||
Version 1.6.1
|
||||
-------------
|
||||
|
||||
Released 2019-11-15
|
||||
|
||||
**New features**
|
||||
|
||||
- Added ``PATRONICTL_CONFIG_FILE`` environment variable (msvechla)
|
||||
@@ -1725,6 +2255,8 @@ Version 1.6.1
|
||||
Version 1.6.0
|
||||
-------------
|
||||
|
||||
Released 2019-08-05
|
||||
|
||||
This version adds compatibility with PostgreSQL 12, makes is possible to run pg_rewind without superuser on PostgreSQL 11 and newer, and enables IPv6 support.
|
||||
|
||||
|
||||
@@ -1837,6 +2369,8 @@ This version adds compatibility with PostgreSQL 12, makes is possible to run pg_
|
||||
Version 1.5.6
|
||||
-------------
|
||||
|
||||
Released 2019-08-03
|
||||
|
||||
**New features**
|
||||
|
||||
- Support work with etcd cluster via set of proxies (Alexander Kukushkin)
|
||||
@@ -1880,6 +2414,8 @@ Version 1.5.6
|
||||
Version 1.5.5
|
||||
-------------
|
||||
|
||||
Released 2019-02-15
|
||||
|
||||
This version introduces the possibility of automatic reinit of the former master, improves patronictl list output and fixes a number of bugs.
|
||||
|
||||
**New features**
|
||||
@@ -1918,6 +2454,8 @@ This version introduces the possibility of automatic reinit of the former master
|
||||
Version 1.5.4
|
||||
-------------
|
||||
|
||||
Released 2019-01-15
|
||||
|
||||
This version implements flexible logging and fixes a number of bugs.
|
||||
|
||||
**New features**
|
||||
@@ -1977,6 +2515,8 @@ This version implements flexible logging and fixes a number of bugs.
|
||||
Version 1.5.3
|
||||
-------------
|
||||
|
||||
Released 2018-12-03
|
||||
|
||||
Compatibility and bugfix release.
|
||||
|
||||
- Improve stability when running with python3 against zookeeper (Alexander Kukushkin)
|
||||
@@ -1994,6 +2534,8 @@ Compatibility and bugfix release.
|
||||
Version 1.5.2
|
||||
-------------
|
||||
|
||||
Released 2018-11-26
|
||||
|
||||
Compatibility and bugfix release.
|
||||
|
||||
- Compatibility with kazoo-2.6.0 (Alexander Kukushkin)
|
||||
@@ -2007,6 +2549,8 @@ Compatibility and bugfix release.
|
||||
Version 1.5.1
|
||||
-------------
|
||||
|
||||
Released 2018-11-01
|
||||
|
||||
This version implements support of permanent replication slots, adds support of pgBackRest and fixes number of bugs.
|
||||
|
||||
**New features**
|
||||
@@ -2023,15 +2567,17 @@ This version implements support of permanent replication slots, adds support of
|
||||
|
||||
- A few bugfixes in the "standby cluster" workflow (Alexander Kukushkin)
|
||||
|
||||
Please see https://github.com/zalando/patroni/pull/823 for more details.
|
||||
Please see https://github.com/patroni/patroni/pull/823 for more details.
|
||||
|
||||
- Fix REST API health check when cluster management is paused and DCS is not accessible (Alexander Kukushkin)
|
||||
|
||||
Regression was introduced in https://github.com/zalando/patroni/commit/90cf930036a9d5249265af15d2b787ec7517cf57
|
||||
Regression was introduced in https://github.com/patroni/patroni/commit/90cf930036a9d5249265af15d2b787ec7517cf57
|
||||
|
||||
Version 1.5.0
|
||||
-------------
|
||||
|
||||
Released 2018-09-20
|
||||
|
||||
This version enables Patroni HA cluster to operate in a standby mode, introduces experimental support for running on Windows, and provides a new configuration parameter to register PostgreSQL service in Consul.
|
||||
|
||||
**New features**
|
||||
@@ -2080,6 +2626,8 @@ This version enables Patroni HA cluster to operate in a standby mode, introduces
|
||||
Version 1.4.6
|
||||
-------------
|
||||
|
||||
Released 2018-08-14
|
||||
|
||||
**Bug fixes and stability improvements**
|
||||
|
||||
This release fixes a critical issue with Patroni API /master endpoint returning 200 for the non-master node. This is a
|
||||
@@ -2097,6 +2645,8 @@ reporting issue, no actual split-brain, but under certain circumstances clients
|
||||
Version 1.4.5
|
||||
-------------
|
||||
|
||||
Released 2018-08-03
|
||||
|
||||
**New features**
|
||||
|
||||
- Improve logging when applying new postgres configuration (Don Seiler)
|
||||
@@ -2161,6 +2711,8 @@ Version 1.4.5
|
||||
Version 1.4.4
|
||||
-------------
|
||||
|
||||
Released 2018-05-22
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Fix race condition in poll_failover_result (Alexander Kukushkin)
|
||||
@@ -2223,6 +2775,8 @@ Version 1.4.4
|
||||
Version 1.4.3
|
||||
-------------
|
||||
|
||||
Released 2018-03-05
|
||||
|
||||
**Improvements in logging**
|
||||
|
||||
- Make log level configurable from environment variables (Andy Newton, Keyvan Hedayati)
|
||||
@@ -2243,12 +2797,14 @@ Version 1.4.3
|
||||
|
||||
- Single user mode was waiting for user input and never finish (Alexander Kukushkin)
|
||||
|
||||
Regression was introduced in https://github.com/zalando/patroni/pull/576
|
||||
Regression was introduced in https://github.com/patroni/patroni/pull/576
|
||||
|
||||
|
||||
Version 1.4.2
|
||||
-------------
|
||||
|
||||
Released 2018-01-30
|
||||
|
||||
**Improvements in patronictl**
|
||||
|
||||
- Rename scheduled failover to scheduled switchover (Alexander Kukushkin)
|
||||
@@ -2292,6 +2848,8 @@ Version 1.4.2
|
||||
Version 1.4.1
|
||||
-------------
|
||||
|
||||
Released 2018-01-17
|
||||
|
||||
**Fixes in patronictl**
|
||||
|
||||
- Don't show current leader in suggested list of members to failover to. (Alexander Kukushkin)
|
||||
@@ -2306,6 +2864,8 @@ Version 1.4.1
|
||||
Version 1.4
|
||||
-----------
|
||||
|
||||
Released 2018-01-10
|
||||
|
||||
This version adds support for using Kubernetes as a DCS, allowing to run Patroni as a cloud-native agent in Kubernetes without any additional deployments of Etcd, Zookeeper or Consul.
|
||||
|
||||
**Upgrade notice**
|
||||
@@ -2323,7 +2883,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
This object identifies a running postmaster process via pid and start time and simplifies detection (and resolution) of situations when the postmaster was restarted behind our back or when postgres directory disappeared from the file system.
|
||||
|
||||
- Minimize the amount of SELECT's issued by Patroni on every loop of HA cylce (Alexander Kukushkin)
|
||||
- Minimize the amount of SELECT's issued by Patroni on every loop of HA cycle (Alexander Kukushkin)
|
||||
|
||||
On every iteration of HA loop Patroni needs to know recovery status and absolute wal position. From now on Patroni will run only single SELECT to get this information instead of two on the replica and three on the master.
|
||||
|
||||
@@ -2347,7 +2907,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Improve ``patronictl reinit`` (Alexander Kukushkin)
|
||||
|
||||
Sometimes ``patronictl reinit`` refused to proceed when Patroni was busy with other actions, namely trying to start postgres. `patronictl` didn't provide any commands to cancel such long running actions and the only (dangerous) workarond was removing a data directory manually. The new implementation of `reinit` forcefully cancells other long-running actions before proceeding with reinit.
|
||||
Sometimes ``patronictl reinit`` refused to proceed when Patroni was busy with other actions, namely trying to start postgres. `patronictl` didn't provide any commands to cancel such long running actions and the only (dangerous) workarond was removing a data directory manually. The new implementation of `reinit` forcefully cancels other long-running actions before proceeding with reinit.
|
||||
|
||||
- Implement ``--wait`` flag in ``patronictl pause`` and ``patronictl resume`` (Alexander Kukushkin)
|
||||
|
||||
@@ -2376,7 +2936,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Add new /sync and /async endpoints (Alexander Kukushkin, Oleksii Kliukin)
|
||||
|
||||
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchronous replicas correspondingly (exclusing those marked as `noloadbalance`).
|
||||
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchronous replicas correspondingly (excluding those marked as `noloadbalance`).
|
||||
|
||||
**Allow multiple hosts for Etcd**
|
||||
|
||||
@@ -2388,6 +2948,8 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
Version 1.3.6
|
||||
-------------
|
||||
|
||||
Released 2017-11-10
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Verify process start time when checking if postgres is running. (Ants Aasma)
|
||||
@@ -2429,6 +2991,8 @@ Version 1.3.6
|
||||
Version 1.3.5
|
||||
-------------
|
||||
|
||||
Released 2017-10-12
|
||||
|
||||
**Bugfix**
|
||||
|
||||
- Set role to 'uninitialized' if data directory was removed (Alexander Kukushkin)
|
||||
@@ -2460,6 +3024,8 @@ Version 1.3.5
|
||||
Version 1.3.4
|
||||
-------------
|
||||
|
||||
Released 2017-09-08
|
||||
|
||||
**Different Consul improvements**
|
||||
|
||||
- Pass the consul token as a header (Andrew Colin Kissa)
|
||||
@@ -2498,6 +3064,8 @@ Version 1.3.4
|
||||
Version 1.3.3
|
||||
-------------
|
||||
|
||||
Released 2017-08-04
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- synchronous replication was disabled shortly after promotion even when synchronous_mode_strict was turned on (Alexander Kukushkin)
|
||||
@@ -2508,6 +3076,8 @@ Version 1.3.3
|
||||
Version 1.3.2
|
||||
-------------
|
||||
|
||||
Released 2017-07-31
|
||||
|
||||
**Bugfix**
|
||||
|
||||
- patronictl edit-config didn't work with ZooKeeper (Alexander Kukushkin)
|
||||
@@ -2516,6 +3086,8 @@ Version 1.3.2
|
||||
Version 1.3.1
|
||||
-------------
|
||||
|
||||
Released 2017-07-28
|
||||
|
||||
**Bugfix**
|
||||
|
||||
- failover via API was broken due to change in ``_MemberStatus`` (Alexander Kukushkin)
|
||||
@@ -2524,6 +3096,8 @@ Version 1.3.1
|
||||
Version 1.3
|
||||
-----------
|
||||
|
||||
Released 2017-07-27
|
||||
|
||||
Version 1.3 adds custom bootstrap possibility, significantly improves support for pg_rewind, enhances the
|
||||
synchronous mode support, adds configuration editing to patronictl and implements watchdog support on Linux.
|
||||
In addition, this is the first version to work correctly with PostgreSQL 10.
|
||||
@@ -2543,7 +3117,7 @@ at the end.
|
||||
Allow custom bootstrap scripts instead of ``initdb`` when initializing the very first node in the cluster.
|
||||
The bootstrap command receives the name of the cluster and the path to the data directory. The resulting cluster can
|
||||
be configured to perform recovery, making it possible to bootstrap from a backup and do point in time recovery. Refer
|
||||
to the :ref:`documentaton page <custom_bootstrap>` for more detailed description of this feature.
|
||||
to the :ref:`documentation page <custom_bootstrap>` for more detailed description of this feature.
|
||||
|
||||
**Smarter pg_rewind support**
|
||||
|
||||
@@ -2652,6 +3226,8 @@ at the end.
|
||||
Version 1.2
|
||||
-----------
|
||||
|
||||
Released 2016-12-13
|
||||
|
||||
This version introduces significant improvements over the handling of synchronous replication, makes the startup process and failover more reliable, adds PostgreSQL 9.6 support and fixes plenty of bugs.
|
||||
In addition, the documentation, including these release notes, has been moved to https://patroni.readthedocs.io.
|
||||
|
||||
@@ -2731,7 +3307,7 @@ In addition, the documentation, including these release notes, has been moved to
|
||||
|
||||
**Documentation improvements**
|
||||
|
||||
- Add a Patroni main `loop workflow diagram <https://raw.githubusercontent.com/zalando/patroni/master/docs/ha_loop_diagram.png>`__. (Alejandro Martínez, Alexander Kukushkin)
|
||||
- Add a Patroni main `loop workflow diagram <https://raw.githubusercontent.com/patroni/patroni/master/docs/ha_loop_diagram.png>`__. (Alejandro Martínez, Alexander Kukushkin)
|
||||
|
||||
- Improve README, adding the Helm chart and links to release notes. (Lauri Apple)
|
||||
|
||||
@@ -2747,6 +3323,8 @@ In addition, the documentation, including these release notes, has been moved to
|
||||
Version 1.1
|
||||
-----------
|
||||
|
||||
Released 2016-09-07
|
||||
|
||||
This release improves management of Patroni cluster by bring in pause mode, improves maintenance with scheduled and conditional restarts, makes Patroni interaction with Etcd or Zookeeper more resilient and greatly enhances patronictl.
|
||||
|
||||
**Upgrade notice**
|
||||
@@ -2844,6 +3422,8 @@ Previously, there was no reliable way to query Patroni about PostgreSQL instance
|
||||
Version 1.0
|
||||
-----------
|
||||
|
||||
Released 2016-07-05
|
||||
|
||||
This release introduces the global dynamic configuration that allows dynamic changes of the PostgreSQL and Patroni configuration parameters for the entire HA cluster. It also delivers numerous bugfixes.
|
||||
|
||||
**Upgrade notice**
|
||||
@@ -2935,6 +3515,8 @@ When upgrading from v0.90 or below, always upgrade all replicas before the maste
|
||||
Version 0.90
|
||||
------------
|
||||
|
||||
Released 2016-04-27
|
||||
|
||||
This releases adds support for Consul, includes a new *noloadbalance* tag, changes the behavior of the *clonefrom* tag, improves *pg_rewind* handling and improves *patronictl* control program.
|
||||
|
||||
**Consul support**
|
||||
@@ -2997,6 +3579,8 @@ This releases adds support for Consul, includes a new *noloadbalance* tag, chang
|
||||
Version 0.80
|
||||
------------
|
||||
|
||||
Released 2016-03-14
|
||||
|
||||
This release adds support for *cascading replication* and simplifies Patroni management by providing *scheduled failovers*. One may use older versions of Patroni (in particular, 0.78) combined with this one in order to migrate to the new release. Note that the scheduled failover and cascading replication related features will only work with Patroni 0.80 and above.
|
||||
|
||||
**Cascading replication**
|
||||
@@ -3037,4 +3621,4 @@ This release adds support for *cascading replication* and simplifies Patroni man
|
||||
|
||||
The tests can be launched manually using the *behave* command. They are also launched automatically for pull requests and after commits.
|
||||
|
||||
Release notes for some older versions can be found on `project's github page <https://github.com/zalando/patroni/releases>`__.
|
||||
Release notes for some older versions can be found on `project's github page <https://github.com/patroni/patroni/releases>`__.
|
||||
|
||||
+36
-57
@@ -1,3 +1,5 @@
|
||||
.. _replica_imaging_and_bootstrap:
|
||||
|
||||
Replica imaging and bootstrap
|
||||
=============================
|
||||
|
||||
@@ -50,7 +52,7 @@ cleans up after itself and releases the initialize lock to give another node the
|
||||
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 (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``.
|
||||
Typically, such recovery configuration should contain at least one of the ``recovery_target_*`` parameters, together with the ``recovery_target_action`` 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 (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).
|
||||
@@ -71,6 +73,21 @@ Makes the configured ``command`` to be called additionally with ``--arg1=value1
|
||||
|
||||
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
||||
|
||||
As an example, you are able to bootstrap a fresh Patroni cluster from a Barman backup with a configuration like this:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
bootstrap:
|
||||
method: barman
|
||||
barman:
|
||||
keep_existing_recovery_conf: true
|
||||
command: patroni_barman --api-url https://barman-host:7480 recover
|
||||
barman-server: my_server
|
||||
ssh-command: ssh postgres@patroni-host
|
||||
|
||||
.. note::
|
||||
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
|
||||
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
|
||||
|
||||
.. _custom_replica_creation:
|
||||
|
||||
@@ -125,6 +142,24 @@ example: pgbackrest
|
||||
basebackup:
|
||||
max-rate: '100M'
|
||||
|
||||
example: Barman
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
postgresql:
|
||||
create_replica_methods:
|
||||
- barman
|
||||
- basebackup
|
||||
barman:
|
||||
command: patroni_barman --api-url https://barman-host:7480 recover
|
||||
barman-server: my_server
|
||||
ssh-command: ssh postgres@patroni-host
|
||||
basebackup:
|
||||
max-rate: '100M'
|
||||
|
||||
.. note::
|
||||
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
|
||||
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
|
||||
|
||||
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
|
||||
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
|
||||
@@ -184,59 +219,3 @@ and
|
||||
- waldir: /pg-wal-mount/external-waldir
|
||||
|
||||
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
|
||||
|
||||
.. _standby_cluster:
|
||||
|
||||
Standby cluster
|
||||
---------------
|
||||
|
||||
Another available option is to run a "standby cluster", that contains only of
|
||||
standby nodes replicating from some remote node. This type of clusters has:
|
||||
|
||||
* "standby leader", that behaves pretty much like a regular cluster leader,
|
||||
except it replicates from a remote node.
|
||||
|
||||
* cascade replicas, that are replicating from standby leader.
|
||||
|
||||
Standby leader holds and updates a leader lock in DCS. If the leader lock
|
||||
expires, cascade replicas will perform an election to choose another leader
|
||||
from the standbys.
|
||||
|
||||
There is no further relationship between the standby cluster and the primary
|
||||
cluster it replicates from, in particular, they must not share the same DCS
|
||||
scope if they use the same DCS. They do not know anything else from each other
|
||||
apart from replication information. Also, the standby cluster is not being
|
||||
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
|
||||
primary cluster.
|
||||
|
||||
For the sake of flexibility, you can specify methods of creating a replica and
|
||||
recovery WAL records when a cluster is in the "standby mode" by providing
|
||||
`create_replica_methods` key in `standby_cluster` section. It is distinct from
|
||||
creating replicas, when cluster is detached and functions as a normal cluster,
|
||||
which is controlled by `create_replica_methods` in `postgresql` section. Both
|
||||
"standby" and "normal" `create_replica_methods` reference keys in `postgresql`
|
||||
section.
|
||||
|
||||
To configure such cluster you need to specify the section ``standby_cluster``
|
||||
in a patroni configuration:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
bootstrap:
|
||||
dcs:
|
||||
standby_cluster:
|
||||
host: 1.2.3.4
|
||||
port: 5432
|
||||
primary_slot_name: patroni
|
||||
create_replica_methods:
|
||||
- basebackup
|
||||
|
||||
Note, that these options will be applied only once during cluster bootstrap,
|
||||
and the only way to change them afterwards is through DCS.
|
||||
|
||||
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
|
||||
of the remote primary and will not start if it does not find it after a
|
||||
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
|
||||
your responsibility to copy it to PGDATA.
|
||||
|
||||
If you use replication slots on the standby cluster, you must also create the corresponding replication slot on the primary cluster. It will not be done automatically by the standby cluster implementation. You can use Patroni's permanent replication slots feature on the primary cluster to maintain a replication slot with the same name as ``primary_slot_name``, or its default value if ``primary_slot_name`` is not provided.
|
||||
|
||||
+111
-14
@@ -6,8 +6,9 @@ Replication modes
|
||||
|
||||
Patroni uses PostgreSQL streaming replication. For more information about streaming replication, see the `Postgres documentation <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__. By default Patroni configures PostgreSQL for asynchronous replication. Choosing your replication schema is dependent on your business considerations. Investigate both async and sync replication, as well as other HA solutions, to determine which solution is best for you.
|
||||
|
||||
|
||||
Asynchronous mode durability
|
||||
----------------------------
|
||||
============================
|
||||
|
||||
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When the primary server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to primary. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the primary, and are effectively unrecoverable [1]_.
|
||||
|
||||
@@ -15,10 +16,11 @@ The amount of transactions that can be lost is controlled via ``maximum_lag_on_f
|
||||
|
||||
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former primary become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
|
||||
|
||||
PostgreSQL synchronous replication
|
||||
----------------------------------
|
||||
|
||||
You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ with Patroni. Synchronous replication ensures consistency across a cluster by confirming that writes are written to a secondary before returning to the connecting client with a success. The cost of synchronous replication: reduced throughput on writes. This throughput will be entirely based on network performance.
|
||||
PostgreSQL synchronous replication
|
||||
==================================
|
||||
|
||||
You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ with Patroni. Synchronous replication ensures consistency across a cluster by confirming that writes are written to a secondary before returning to the connecting client with a success. The cost of synchronous replication: increased latency and reduced throughput on writes. This throughput will be entirely based on network performance.
|
||||
|
||||
In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
|
||||
|
||||
@@ -33,10 +35,11 @@ When using PostgreSQL synchronous replication, use at least three Postgres data
|
||||
|
||||
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When the primary and the secondary that is currently acting as a synchronous replica fail simultaneously a third node that might not contain all transactions will be promoted.
|
||||
|
||||
|
||||
.. _synchronous_mode:
|
||||
|
||||
Synchronous mode
|
||||
----------------
|
||||
================
|
||||
|
||||
For use cases where losing committed transactions is not permissible you can turn on Patroni's ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commands to promote a standby even if it results in transaction loss.
|
||||
|
||||
@@ -53,32 +56,126 @@ are available. As a downside, the primary is not be available for writes
|
||||
blocking all client write requests until at least one synchronous replica comes
|
||||
up.
|
||||
|
||||
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
|
||||
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby. Setting tag ``nostream`` to true will also have the same effect.
|
||||
|
||||
Synchronous mode can be switched on and off via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
|
||||
Synchronous mode can be switched on and off using ``patronictl edit-config`` command or via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
|
||||
|
||||
Note: Because of the way synchronous replication is implemented in PostgreSQL it is still possible to lose transactions even when using ``synchronous_mode_strict``. If the PostgreSQL backend is cancelled while waiting to acknowledge replication (as a result of packet cancellation due to client timeout or backend failure) transaction changes become visible for other backends. Such changes are not yet replicated and may be lost in case of standby promotion.
|
||||
|
||||
|
||||
Synchronous Replication Factor
|
||||
------------------------------
|
||||
The parameter ``synchronous_node_count`` is used by Patroni to manage number of synchronous standby databases. It is set to 1 by default. It has no effect when ``synchronous_mode`` is set to off. When enabled, Patroni manages precise number of synchronous standby databases based on parameter ``synchronous_node_count`` and adjusts the state in DCS & synchronous_standby_names as members join and leave.
|
||||
==============================
|
||||
|
||||
The parameter ``synchronous_node_count`` is used by Patroni to manage the number of synchronous standby databases. It is set to ``1`` by default. It has no effect when ``synchronous_mode`` is set to ``off``. When enabled, Patroni manages the precise number of synchronous standby databases based on parameter ``synchronous_node_count`` and adjusts the state in DCS & ``synchronous_standby_names`` in PostgreSQL as members join and leave. If the parameter is set to a value higher than the number of eligible nodes it will be automatically reduced by Patroni.
|
||||
|
||||
|
||||
Maximum lag on synchronous node
|
||||
===============================
|
||||
|
||||
By default Patroni sticks to nodes that are declared as ``synchronous``, according to the ``pg_stat_replication`` view, even when there are other nodes ahead of it. This is done to minimize the number of changes of ``synchronous_standby_names``. To change this behavior one may use ``maximum_lag_on_syncnode`` parameter. It controls how much lag the replica can have to still be considered as "synchronous".
|
||||
|
||||
Patroni utilizes the max replica LSN if there is more than one standby, otherwise it will use leader's current wal LSN. The default is ``-1``, and Patroni will not take action to swap a synchronous unhealthy standby when the value is set to ``0`` or less. Please set the value high enough so that Patroni won't swap synchronous standbys frequently during high transaction volume.
|
||||
|
||||
|
||||
Synchronous mode implementation
|
||||
-------------------------------
|
||||
===============================
|
||||
|
||||
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest primary and current synchronous standby databases. This state is updated with strict ordering constraints to ensure the following invariants:
|
||||
When in synchronous mode Patroni maintains synchronization state in the DCS (``/sync`` key), containing the latest primary and current synchronous standby databases. This state is updated with strict ordering constraints to ensure the following invariants:
|
||||
|
||||
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
|
||||
|
||||
- A node must be set as the synchronous standby in PostgreSQL as long as it is published as the synchronous standby.
|
||||
- A node must be set as the synchronous standby in PostgreSQL as long as it is published as the synchronous standby in the ``/sync`` key in DCS..
|
||||
|
||||
- A node that is not the leader or current synchronous standby is not allowed to promote itself automatically.
|
||||
|
||||
Patroni will only assign one or more synchronous standby nodes based on ``synchronous_node_count`` parameter to ``synchronous_standby_names``.
|
||||
|
||||
On each HA loop iteration Patroni re-evaluates synchronous standby nodes choice. If the current list of synchronous standby nodes are connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member available for sync that is furthest ahead in replication is picked.
|
||||
On each HA loop iteration Patroni re-evaluates synchronous standby nodes choice. If the current list of synchronous standby nodes are connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster members available for sync that are furthest ahead in replication are picked.
|
||||
|
||||
Example:
|
||||
---------
|
||||
|
||||
``/config`` key in DCS
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: YAML
|
||||
|
||||
synchronous_mode: on
|
||||
synchronous_node_count: 2
|
||||
...
|
||||
|
||||
``/sync`` key in DCS
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: JSON
|
||||
|
||||
{
|
||||
"leader": "node0",
|
||||
"sync_standby": "node1,node2"
|
||||
}
|
||||
|
||||
postgresql.conf
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: INI
|
||||
|
||||
synchronous_standby_names = 'FIRST 2 (node1,node2)'
|
||||
|
||||
|
||||
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster.
|
||||
In the above examples only nodes ``node1`` and ``node2`` are known to be synchronous and allowed to be automatically promoted if the primary (``node0``) fails.
|
||||
|
||||
|
||||
.. _quorum_mode:
|
||||
|
||||
Quorum commit mode
|
||||
==================
|
||||
|
||||
Starting from PostgreSQL v10 Patroni supports quorum-based synchronous replication.
|
||||
|
||||
In this mode, Patroni maintains synchronization state in the DCS, containing the latest known primary, the number of nodes required for quorum, and the nodes currently eligible to vote on quorum. In steady state, the nodes voting on quorum are the leader and all synchronous standbys. This state is updated with strict ordering constraints, with regards to node promotion and ``synchronous_standby_names``, to ensure that at all times any subset of voters that can achieve quorum includes at least one node with the latest successful commit.
|
||||
|
||||
On each iteration of HA loop, Patroni re-evaluates synchronous standby choices and quorum, based on node availability and requested cluster configuration. In PostgreSQL versions above 9.6 all eligible nodes are added as synchronous standbys as soon as their replication catches up to leader.
|
||||
|
||||
Quorum commit helps to reduce worst case latencies, even during normal operation, as a higher latency of replicating to one standby can be compensated by other standbys.
|
||||
|
||||
The quorum-based synchronous mode could be enabled by setting ``synchronous_mode`` to ``quorum`` using ``patronictl edit-config`` command or via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
|
||||
|
||||
Other parameters, like ``synchronous_node_count``, ``maximum_lag_on_syncnode``, and ``synchronous_mode_strict`` continue to work the same way as with ``synchronous_mode=on``.
|
||||
|
||||
Example:
|
||||
---------
|
||||
|
||||
``/config`` key in DCS
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: YAML
|
||||
|
||||
synchronous_mode: quorum
|
||||
synchronous_node_count: 2
|
||||
...
|
||||
|
||||
``/sync`` key in DCS
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: JSON
|
||||
|
||||
{
|
||||
"leader": "node0",
|
||||
"sync_standby": "node1,node2,node3",
|
||||
"quorum": 1
|
||||
}
|
||||
|
||||
postgresql.conf
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: INI
|
||||
|
||||
synchronous_standby_names = 'ANY 2 (node1,node2,node3)'
|
||||
|
||||
|
||||
If the primary (``node0``) failed, in the above example two of the ``node1``, ``node2``, ``node3`` will have the latest transaction received, but we don't know which ones. To figure out whether the node ``node1`` has received the latest transaction, we need to compare its LSN with the LSN on **at least** one node (``quorum=1`` in the ``/sync`` key) among ``node2`` and ``node3``. If ``node1`` isn't behind of at least one of them, we can guarantee that there will be no user visible data loss if ``node1`` is promoted.
|
||||
|
||||
|
||||
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster. However, for ``use_pg_rewind`` to function properly, either the cluster must be initialized with ``data page checksums`` (``--data-checksums`` option for ``initdb``) and/or ``wal_log_hints`` must be set to ``on``.
|
||||
|
||||
.. [2] Clients can change the behavior per transaction using PostgreSQL's ``synchronous_commit`` setting. Transactions with ``synchronous_commit`` values of ``off`` and ``local`` may be lost on fail over, but will not be blocked by replication delays.
|
||||
|
||||
+42
-26
@@ -45,6 +45,10 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
||||
|
||||
- ``GET /read-only-sync``: like the above endpoint, but also includes the primary.
|
||||
|
||||
- ``GET /quorum``: returns HTTP status code **200** only when this Patroni node is listed as a quorum node in ``synchronous_standby_names`` on the primary.
|
||||
|
||||
- ``GET /read-only-quorum``: like the above endpoint, but also includes the primary.
|
||||
|
||||
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
|
||||
|
||||
|
||||
@@ -99,9 +103,9 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:03:37.966359+00:00",
|
||||
"role": "master",
|
||||
"server_version": 150004,
|
||||
"postmaster_start_time": "2024-08-28 19:39:26.352526+00:00",
|
||||
"role": "primary",
|
||||
"server_version": 160004,
|
||||
"xlog": {
|
||||
"location": 67395656
|
||||
},
|
||||
@@ -130,7 +134,7 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"version": "4.0.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
@@ -143,9 +147,9 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
|
||||
"postmaster_start_time": "2024-08-28 19:39:26.352526+00:00",
|
||||
"role": "replica",
|
||||
"server_version": 150004,
|
||||
"server_version": 160004,
|
||||
"xlog": {
|
||||
"received_location": 67419744,
|
||||
"replayed_location": 67419744,
|
||||
@@ -178,7 +182,7 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"version": "4.0.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
@@ -191,9 +195,9 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
|
||||
"postmaster_start_time": "2024-08-28 19:39:26.352526+00:00",
|
||||
"role": "replica",
|
||||
"server_version": 150004,
|
||||
"server_version": 160004,
|
||||
"xlog": {
|
||||
"location": 67420024
|
||||
},
|
||||
@@ -224,7 +228,7 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"version": "4.0.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
@@ -237,9 +241,9 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"state": "running",
|
||||
"postmaster_start_time": "2023-08-18 11:09:08.615242+00:00",
|
||||
"postmaster_start_time": "2024-08-28 19:39:26.352526+00:00",
|
||||
"role": "replica",
|
||||
"server_version": 150004,
|
||||
"server_version": 160004,
|
||||
"xlog": {
|
||||
"location": 67420024
|
||||
},
|
||||
@@ -263,13 +267,13 @@ The ``GET /patroni`` is used by Patroni during the leader race. It also could be
|
||||
}
|
||||
],
|
||||
"pause": true,
|
||||
"dcs_last_seen": 1692356928,
|
||||
"dcs_last_seen": 1724874295,
|
||||
"tags": {
|
||||
"clonefrom": true
|
||||
},
|
||||
"database_system_identifier": "7268616322854375442",
|
||||
"patroni": {
|
||||
"version": "3.1.0",
|
||||
"version": "4.0.0",
|
||||
"scope": "demo",
|
||||
"name": "patroni1"
|
||||
}
|
||||
@@ -283,16 +287,13 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
|
||||
|
||||
# HELP patroni_version Patroni semver without periods. \
|
||||
# TYPE patroni_version gauge
|
||||
patroni_version{scope="batman",name="patroni1"} 020103
|
||||
patroni_version{scope="batman",name="patroni1"} 040000
|
||||
# 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
|
||||
patroni_postmaster_start_time{scope="batman",name="patroni1"} 1724873966.352526
|
||||
# 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
|
||||
@@ -308,6 +309,9 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
|
||||
# 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_quorum_standby Value is 1 if this node is a quorum standby replica, 0 otherwise.
|
||||
# TYPE patroni_quorum_standby gauge
|
||||
patroni_quorum_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
|
||||
@@ -328,7 +332,7 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
|
||||
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
|
||||
patroni_postgres_server_version{scope="batman",name="patroni1"} 160004
|
||||
# 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
|
||||
@@ -340,7 +344,7 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
|
||||
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
|
||||
patroni_dcs_last_seen{scope="batman",name="patroni1"} 1724874235
|
||||
# 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
|
||||
@@ -426,6 +430,7 @@ Cluster status endpoints
|
||||
]
|
||||
]
|
||||
|
||||
.. _config_endpoint:
|
||||
|
||||
Config endpoint
|
||||
---------------
|
||||
@@ -487,20 +492,29 @@ Let's check that the node processed this configuration. First of all it should s
|
||||
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"pending_restart": true,
|
||||
"database_system_identifier": "6287881213849985952",
|
||||
"postmaster_start_time": "2016-06-13 13:13:05.211 CEST",
|
||||
"postmaster_start_time": "2024-08-28 19:39:26.352526+00:00",
|
||||
"xlog": {
|
||||
"location": 2197818976
|
||||
},
|
||||
"timeline": 1,
|
||||
"dcs_last_seen": 1724874545,
|
||||
"database_system_identifier": "7408277255830290455",
|
||||
"pending_restart": true,
|
||||
"pending_restart_reason": {
|
||||
"max_connections": {
|
||||
"old_value": "100",
|
||||
"new_value": "101"
|
||||
}
|
||||
},
|
||||
"patroni": {
|
||||
"version": "1.0",
|
||||
"version": "4.0.0",
|
||||
"scope": "batman",
|
||||
"name": "patroni1"
|
||||
},
|
||||
"state": "running",
|
||||
"role": "master",
|
||||
"server_version": 90503
|
||||
"role": "primary",
|
||||
"server_version": 160004
|
||||
}
|
||||
|
||||
Removing parameters:
|
||||
@@ -666,6 +680,7 @@ There are a couple of checks that a member of a cluster should pass to be able t
|
||||
- its lag exceeds the maximum replication lag allowed;
|
||||
- it has the timeline number smaller than the last known cluster timeline.
|
||||
|
||||
.. _restart_endpoint:
|
||||
|
||||
Restart endpoint
|
||||
----------------
|
||||
@@ -682,6 +697,7 @@ Restart endpoint
|
||||
|
||||
``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
|
||||
---------------
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
.. _standby_cluster:
|
||||
|
||||
Standby cluster
|
||||
---------------
|
||||
|
||||
Patroni also support running cascading replication to a remote datacenter
|
||||
(region) using a feature that is called "standby cluster". This type of
|
||||
clusters has:
|
||||
|
||||
* "standby leader", that behaves pretty much like a regular cluster leader,
|
||||
except it replicates from a remote node.
|
||||
|
||||
* cascade replicas, that are replicating from standby leader.
|
||||
|
||||
Standby leader holds and updates a leader lock in DCS. If the leader lock
|
||||
expires, cascade replicas will perform an election to choose another leader
|
||||
from the standbys.
|
||||
|
||||
There is no further relationship between the standby cluster and the primary
|
||||
cluster it replicates from, in particular, they must not share the same DCS
|
||||
scope if they use the same DCS. They do not know anything else from each other
|
||||
apart from replication information. Also, the standby cluster is not being
|
||||
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
|
||||
primary cluster.
|
||||
|
||||
For the sake of flexibility, you can specify methods of creating a replica and
|
||||
recovery WAL records when a cluster is in the "standby mode" by providing
|
||||
:ref:`create_replica_methods <custom_replica_creation>` key in
|
||||
`standby_cluster` section. It is distinct from creating replicas, when cluster
|
||||
is detached and functions as a normal cluster, which is controlled by
|
||||
`create_replica_methods` in `postgresql` section. Both "standby" and "normal"
|
||||
`create_replica_methods` reference keys in `postgresql` section.
|
||||
|
||||
To configure such cluster you need to specify the section ``standby_cluster``
|
||||
in a patroni configuration:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
bootstrap:
|
||||
dcs:
|
||||
standby_cluster:
|
||||
host: 1.2.3.4
|
||||
port: 5432
|
||||
primary_slot_name: patroni
|
||||
create_replica_methods:
|
||||
- basebackup
|
||||
|
||||
Note, that these options will be applied only once during cluster bootstrap,
|
||||
and the only way to change them afterwards is through DCS.
|
||||
|
||||
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
|
||||
of the remote primary and will not start if it does not find it after a
|
||||
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
|
||||
your responsibility to copy it to PGDATA.
|
||||
|
||||
If you use replication slots on the standby cluster, you must also create the
|
||||
corresponding replication slot on the primary cluster. It will not be done
|
||||
automatically by the standby cluster implementation. You can use Patroni's
|
||||
permanent replication slots feature on the primary cluster to maintain a
|
||||
replication slot with the same name as ``primary_slot_name``, or its default
|
||||
value if ``primary_slot_name`` is not provided.
|
||||
|
||||
In case the remote site doesn't provide a single endpoint that connects to a
|
||||
primary, one could list all hosts of the source cluster in the
|
||||
``standby_cluster.host`` section. When ``standby_cluster.host`` contains
|
||||
multiple hosts separated by commas, Patroni will:
|
||||
|
||||
* add ``target_session_attrs=read-write`` to the ``primary_conninfo`` on the
|
||||
standby leader node.
|
||||
* use ``target_session_attrs=read-write`` when trying to determine whether we
|
||||
need to run ``pg_rewind`` or when executing ``pg_rewind`` on all nodes of the
|
||||
standby cluster.
|
||||
* It is important to note that for ``pg_rewind`` to operate successfully,
|
||||
either the cluster must be initialized with ``data page checksums``
|
||||
(``--data-checksums`` option for ``initdb``) and/or ``wal_log_hints`` must be set to ``on``.
|
||||
Otherwise, ``pg_rewind`` will not function properly.
|
||||
|
||||
There is also a possibility to replicate the standby cluster from another
|
||||
standby cluster or from a standby member of the primary cluster: for that, you
|
||||
need to define a single host in the ``standby_cluster.host`` section. However,
|
||||
you need to beware that in this case ``pg_rewind`` will fail to execute on the
|
||||
standby cluster.
|
||||
@@ -0,0 +1,64 @@
|
||||
.. _tools_integration:
|
||||
|
||||
Integration with other tools
|
||||
============================
|
||||
|
||||
Patroni is able to integrate with other tools in your stack. In this section you
|
||||
will find a list of examples, which although not an exhaustive list, might
|
||||
provide you with ideas on how Patroni can integrate with other tools.
|
||||
|
||||
Barman
|
||||
------
|
||||
|
||||
Patroni delivers an application named ``patroni_barman`` which has logic to
|
||||
communicate with ``pg-backup-api``, so you are able to perform Barman operations
|
||||
remotely.
|
||||
|
||||
This application currently has a couple of sub-commands: ``recover`` and
|
||||
``config-switch``.
|
||||
|
||||
patroni_barman recover
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The ``recover`` sub-command can be used as a custom bootstrap or custom replica
|
||||
creation method. You can find more information about that in
|
||||
:ref:`replica_imaging_and_bootstrap`.
|
||||
|
||||
patroni_barman config-switch
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The ``config-switch`` sub-command is designed to be used as an ``on_role_change``
|
||||
callback in Patroni. As an example, assume you are streaming WALs from your
|
||||
current primary to your Barman host. In the event of a failover in the cluster
|
||||
you might want to start streaming WALs from the new primary. You can accomplish
|
||||
this by using ``patroni_barman config-switch`` as the ``on_role_change`` callback.
|
||||
|
||||
.. note::
|
||||
That sub-command relies on the ``barman config-switch`` command, which is in
|
||||
charge of overriding the configuration of a Barman server by applying a
|
||||
pre-defined model on top of it. This command is available since Barman 3.10.
|
||||
Please consult the Barman documentation for more details.
|
||||
|
||||
This is an example of how you can configure Patroni to apply a configuration
|
||||
model in case this Patroni node is promoted to primary:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
postgresql:
|
||||
callbacks:
|
||||
on_role_change: >
|
||||
patroni_barman
|
||||
--api-url YOUR_API_URL
|
||||
config-switch
|
||||
--barman-server YOUR_BARMAN_SERVER_NAME
|
||||
--barman-model YOUR_BARMAN_MODEL_NAME
|
||||
--switch-when promoted
|
||||
|
||||
.. note::
|
||||
``patroni_barman config-switch`` requires that you have both Barman and
|
||||
``pg-backup-api`` configured in the Barman host, so it can execute a remote
|
||||
``barman config-switch`` through the backup API. Also, it requires that you
|
||||
have pre-configured Barman models to be applied. The above example uses a
|
||||
subset of the available parameters. You can get more information running
|
||||
``patroni_barman config-switch --help``, and by consulting the Barman
|
||||
documentation.
|
||||
+51
-15
@@ -11,20 +11,49 @@ Global/Universal
|
||||
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||
- **scope**: cluster name
|
||||
|
||||
.. _log_settings:
|
||||
|
||||
Log
|
||||
---
|
||||
- **type**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
|
||||
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
|
||||
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
|
||||
- **format**: sets the log formatting string. If the log type is **plain**, the log format should be a string. Refer to
|
||||
`the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
|
||||
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
|
||||
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
|
||||
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
|
||||
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
|
||||
Default value is **%(asctime)s %(levelname)s: %(message)s**
|
||||
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
|
||||
- **static_fields**: add additional fields to the log. This option is only available when the log type is set to **json**.
|
||||
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
|
||||
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
|
||||
- **mode**: Permissions for log files (for example, ``0644``). If not specified, permissions will be set based on the current umask value.
|
||||
- **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**
|
||||
- **deduplicate_heartbeat_logs**: If set to ``true``, successive heartbeat logs that are identical shall not be output. Default value is ``false``.
|
||||
|
||||
.. warning::
|
||||
The time the HA loop executes at can be very valuable information in diagnosing failovers due to resource exhaustion and similar problems. When ``deduplicate_heartbeat_logs`` is set to ``true`` there will be no log generated for the HA loop execution (unless the leader changes) and hence this potentially useful information will not be available from the logs.
|
||||
|
||||
Here is an example of how to config patroni to log in json format.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
log:
|
||||
type: json
|
||||
format:
|
||||
- message
|
||||
- module
|
||||
- asctime: '@timestamp'
|
||||
- levelname: level
|
||||
static_fields:
|
||||
app: patroni
|
||||
|
||||
.. _bootstrap_settings:
|
||||
|
||||
@@ -79,9 +108,9 @@ Most of the parameters are optional, but you have to specify one of the **host**
|
||||
- **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\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``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>`__.
|
||||
- **service\_check\_tls\_server\_name**: (optional) override 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:
|
||||
|
||||
@@ -120,7 +149,7 @@ 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.
|
||||
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. In addition, Patroni uses Etcd's gRPC-gateway (proxy) to communicate with the V3 API, which means that TLS common name authentication is not possible.
|
||||
|
||||
|
||||
ZooKeeper
|
||||
@@ -133,6 +162,7 @@ ZooKeeper
|
||||
- **key_password**: (optional) The client key password.
|
||||
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||
- **auth_data**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary.
|
||||
|
||||
.. note::
|
||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||
@@ -152,11 +182,12 @@ Kubernetes
|
||||
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
|
||||
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
|
||||
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||
- **role\_label**: (optional) name of the label containing role (master or replica 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``.
|
||||
- **bootstrap\_labels**: (optional) Labels in format ``{label1: value1, label2: value2}``. These labels will be assigned to a Patroni pod when its state is either ``initializing new cluster``, ``running custom bootstrap script``, ``starting after custom bootstrap`` or ``creating replica``.
|
||||
- **role\_label**: (optional) name of the label containing role (`primary`, `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 ``primary``. Default value is ``primary``.
|
||||
- **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.
|
||||
- **standby\_leader\_label\_value**: (optional) value of the pod label when Postgres role is ``standby_leader``. Default value is ``primary``.
|
||||
- **tmp\_role\_label**: (optional) name of the temporary label containing role (`primary` 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.
|
||||
@@ -213,9 +244,10 @@ PostgreSQL
|
||||
- **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.
|
||||
- **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 or 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.
|
||||
- **sslnegotiation**: (optional) maps to the `sslnegotiation <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION>`__ connection parameter, which controls how SSL encryption is negotiated with the server, if SSL is used.
|
||||
- **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**:
|
||||
@@ -226,9 +258,10 @@ PostgreSQL
|
||||
- **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.
|
||||
- **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 or 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.
|
||||
- **sslnegotiation**: (optional) maps to the `sslnegotiation <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION>`__ connection parameter, which controls how SSL encryption is negotiated with the server, if SSL is used.
|
||||
- **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**:
|
||||
@@ -239,9 +272,10 @@ PostgreSQL
|
||||
- **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.
|
||||
- **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 or 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.
|
||||
- **sslnegotiation**: (optional) maps to the `sslnegotiation <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION>`__ connection parameter, which controls how SSL encryption is negotiated with the server, if SSL is used.
|
||||
- **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.
|
||||
|
||||
@@ -275,7 +309,7 @@ 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, 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.
|
||||
- **parameters**: configuration parameters (GUCs) for Postgres in format ``{ssl: "on", ssl_cert_file: "cert_file"}``.
|
||||
- **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**
|
||||
@@ -285,7 +319,7 @@ PostgreSQL
|
||||
- **- 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.
|
||||
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica. Either the cluster must be initialized with ``data page checksums`` (``--data-checksums`` option for ``initdb``) and/or ``wal_log_hints`` must be set to ``on``, or ``pg_rewind`` will not work.
|
||||
- **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".
|
||||
@@ -370,10 +404,12 @@ 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.
|
||||
- **replicatefrom**: The name of another replica to replicate from. Used to support cascading replication.
|
||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||
- **sync_priority**: integer, controls the priority this node should have during synchronous replica selection when ``synchronous_mode`` is set to ``on``. Nodes with higher priority will be preferred over lower-priority nodes. If the ``sync_priority`` is 0 or negative - such node is not allowed to be written to ``synchronous_standby_names`` PostgreSQL parameter (similar to ``nosync: true``). Keep in mind, that this parameter has the opposite meaning to ``sync_priority`` value reported in ``pg_stat_replication`` view.
|
||||
- **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``).
|
||||
- **failover_priority**: integer, controls the priority this node should have during failover. Nodes with higher priority will be preferred over lower-priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
|
||||
- **nostream**: ``true`` or ``false``. If set to ``true`` the node will not use replication protocol to stream WAL. It will rely instead on archive recovery (if ``restore_command`` is configured) and ``pg_wal``/``pg_xlog`` polling. It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas. Setting this tag on primary node has no effect.
|
||||
|
||||
.. warning::
|
||||
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
|
||||
|
||||
@@ -6,7 +6,7 @@ Description=Runners to orchestrate a high-availability PostgreSQL
|
||||
After=syslog.target network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Type=notify
|
||||
|
||||
User=postgres
|
||||
Group=postgres
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,12 +3,18 @@ import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from time import sleep
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--datadir", required=True)
|
||||
parser.add_argument("--dbname", required=True)
|
||||
parser.add_argument("--walmethod", required=True, choices=("fetch", "stream", "none"))
|
||||
parser.add_argument("--sleep", required=False, type=int)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
if args.sleep:
|
||||
sleep(args.sleep)
|
||||
|
||||
walmethod = ["-X", args.walmethod] if args.walmethod != "none" else []
|
||||
sys.exit(subprocess.call(["pg_basebackup", "-D", args.datadir, "-c", "fast", "-d", args.dbname] + walmethod))
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
import argparse
|
||||
import shutil
|
||||
|
||||
from time import sleep
|
||||
|
||||
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)
|
||||
parser.add_argument("--sleep", required=False, type=int)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
if args.sleep:
|
||||
sleep(args.sleep)
|
||||
|
||||
shutil.copytree(args.sourcedir, args.datadir)
|
||||
|
||||
@@ -2,57 +2,57 @@ Feature: basic replication
|
||||
We should check that the basic bootstrapping, replication and failover works.
|
||||
|
||||
Scenario: check replication of a single table
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
Given I start postgres-0
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "synchronous_mode": true}
|
||||
Then I receive a response code 200
|
||||
When I start postgres1
|
||||
And I configure and start postgres2 with a tag replicatefrom postgres0
|
||||
And "sync" key in DCS has leader=postgres0 after 20 seconds
|
||||
And I add the table foo to postgres0
|
||||
Then table foo is present on postgres1 after 20 seconds
|
||||
Then table foo is present on postgres2 after 20 seconds
|
||||
When I start postgres-1
|
||||
And I configure and start postgres-2 with a tag replicatefrom postgres-0
|
||||
And "sync" key in DCS has leader=postgres-0 after 20 seconds
|
||||
And I add the table foo to postgres-0
|
||||
Then table foo is present on postgres-1 after 20 seconds
|
||||
Then table foo is present on postgres-2 after 20 seconds
|
||||
|
||||
Scenario: check restart of sync replica
|
||||
Given I shut down postgres2
|
||||
Then "sync" key in DCS has sync_standby=postgres1 after 5 seconds
|
||||
When I start postgres2
|
||||
And I shut down postgres1
|
||||
Then "sync" key in DCS has sync_standby=postgres2 after 10 seconds
|
||||
When I start postgres1
|
||||
Then "members/postgres1" key in DCS has state=running after 10 seconds
|
||||
Given I shut down postgres-2
|
||||
Then "sync" key in DCS has sync_standby=postgres-1 after 5 seconds
|
||||
When I start postgres-2
|
||||
And I shut down postgres-1
|
||||
Then "sync" key in DCS has sync_standby=postgres-2 after 10 seconds
|
||||
When I start postgres-1
|
||||
Then "members/postgres-1" key in DCS has state=running after 10 seconds
|
||||
And Status code on GET http://127.0.0.1:8010/sync is 200 after 3 seconds
|
||||
And Status code on GET http://127.0.0.1:8009/async is 200 after 3 seconds
|
||||
|
||||
Scenario: check stuck sync replica
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"pause": true, "maximum_lag_on_syncnode": 15000000, "postgresql": {"parameters": {"synchronous_commit": "remote_apply"}}}
|
||||
Then I receive a response code 200
|
||||
And I create table on postgres0
|
||||
And table mytest is present on postgres1 after 2 seconds
|
||||
And table mytest is present on postgres2 after 2 seconds
|
||||
When I pause wal replay on postgres2
|
||||
And I load data on postgres0
|
||||
Then "sync" key in DCS has sync_standby=postgres1 after 15 seconds
|
||||
And I resume wal replay on postgres2
|
||||
And I create table on postgres-0
|
||||
And table mytest is present on postgres-1 after 2 seconds
|
||||
And table mytest is present on postgres-2 after 2 seconds
|
||||
When I pause wal replay on postgres-2
|
||||
And I load data on postgres-0
|
||||
Then "sync" key in DCS has sync_standby=postgres-1 after 15 seconds
|
||||
And I resume wal replay on postgres-2
|
||||
And Status code on GET http://127.0.0.1:8009/sync is 200 after 3 seconds
|
||||
And Status code on GET http://127.0.0.1:8010/async is 200 after 3 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"pause": null, "maximum_lag_on_syncnode": -1, "postgresql": {"parameters": {"synchronous_commit": "on"}}}
|
||||
Then I receive a response code 200
|
||||
And I drop table on postgres0
|
||||
And I drop table on postgres-0
|
||||
|
||||
Scenario: check multi sync replication
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_node_count": 2}
|
||||
Then I receive a response code 200
|
||||
Then "sync" key in DCS has sync_standby=postgres1,postgres2 after 10 seconds
|
||||
Then "sync" key in DCS has sync_standby=postgres-1,postgres-2 after 10 seconds
|
||||
And Status code on GET http://127.0.0.1:8010/sync is 200 after 3 seconds
|
||||
And Status code on GET http://127.0.0.1:8009/sync is 200 after 3 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_node_count": 1}
|
||||
Then I receive a response code 200
|
||||
And I shut down postgres1
|
||||
Then "sync" key in DCS has sync_standby=postgres2 after 10 seconds
|
||||
When I start postgres1
|
||||
Then "members/postgres1" key in DCS has state=running after 10 seconds
|
||||
And I shut down postgres-1
|
||||
Then "sync" key in DCS has sync_standby=postgres-2 after 10 seconds
|
||||
When I start postgres-1
|
||||
Then "members/postgres-1" key in DCS has state=running after 10 seconds
|
||||
And Status code on GET http://127.0.0.1:8010/sync is 200 after 3 seconds
|
||||
And Status code on GET http://127.0.0.1:8009/async is 200 after 3 seconds
|
||||
|
||||
@@ -60,26 +60,26 @@ Feature: basic replication
|
||||
Given I run patronictl.py pause batman
|
||||
Then I receive a response returncode 0
|
||||
When I sleep for 2 seconds
|
||||
And I shut down postgres0
|
||||
And I shut down postgres-0
|
||||
And I run patronictl.py resume batman
|
||||
Then I receive a response returncode 0
|
||||
And postgres2 role is the primary after 24 seconds
|
||||
And postgres-2 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
|
||||
And there is a postgres-2_cb.log with "on_role_change primary batman" in postgres-2 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
|
||||
When I add the table bar to postgres-2
|
||||
Then table bar is present on postgres-1 after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8010/config contains master_start_timeout 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 postgres2
|
||||
Then table buz is present on postgres0 after 20 seconds
|
||||
Given I add the table splitbrain to postgres-0
|
||||
And I start postgres-0
|
||||
Then postgres-0 role is the secondary after 20 seconds
|
||||
When I add the table buz to postgres-2
|
||||
Then table buz is present on postgres-0 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
|
||||
Given I start duplicate postgres-0 on port 8011
|
||||
Then there is one of ["Can't start; there is already a node named 'postgres-0' running"] CRITICAL in the dup-postgres-0 patroni log after 5 seconds
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
Feature: bootstrap labels
|
||||
Check that user-configurable bootstrap labels are set and removed with state change
|
||||
|
||||
Scenario: check label for cluster bootstrap
|
||||
When I start postgres-0
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
When I start postgres-1 in a cluster batman1 as a long-running clone of postgres-0
|
||||
Then "members/postgres-1" key in DCS has state=running custom bootstrap script after 20 seconds
|
||||
And postgres-1 is labeled with "foo"
|
||||
And postgres-1 is a leader of batman1 after 20 seconds
|
||||
|
||||
Scenario: check label for replica bootstrap
|
||||
When I do a backup of postgres-1
|
||||
And I start postgres-2 in cluster batman1 using long-running backup_restore
|
||||
Then "members/postgres-2" key in DCS has state=creating replica after 20 seconds
|
||||
And postgres-2 is labeled with "foo"
|
||||
|
||||
Scenario: check bootstrap label is removed
|
||||
Given "members/postgres-1" key in DCS has state=running after 2 seconds
|
||||
And "members/postgres-2" key in DCS has state=running after 20 seconds
|
||||
Then postgres-1 is not labeled with "foo"
|
||||
And postgres-2 is not labeled with "foo"
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import sys
|
||||
|
||||
with open("data/{0}/{0}_cb.log".format(sys.argv[1]), "a+") as log:
|
||||
log.write(" ".join(sys.argv[-3:]) + "\n")
|
||||
|
||||
@@ -2,13 +2,13 @@ Feature: cascading replication
|
||||
We should check that patroni can do base backup and streaming from the replica
|
||||
|
||||
Scenario: check a base backup and streaming replication from a replica
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
And I configure and start postgres1 with a tag clonefrom true
|
||||
And replication works from postgres0 to postgres1 after 20 seconds
|
||||
And I create label with "postgres0" in postgres0 data directory
|
||||
And I create label with "postgres1" in postgres1 data directory
|
||||
And "members/postgres1" key in DCS has state=running after 12 seconds
|
||||
And I configure and start postgres2 with a tag replicatefrom postgres1
|
||||
Then replication works from postgres0 to postgres2 after 30 seconds
|
||||
And there is a label with "postgres1" in postgres2 data directory
|
||||
Given I start postgres-0
|
||||
And postgres-0 is a leader after 10 seconds
|
||||
And I configure and start postgres-1 with a tag clonefrom true
|
||||
And replication works from postgres-0 to postgres-1 after 20 seconds
|
||||
And I create label with "postgres-0" in postgres-0 data directory
|
||||
And I create label with "postgres-1" in postgres-1 data directory
|
||||
And "members/postgres-1" key in DCS has state=running after 12 seconds
|
||||
And I configure and start postgres-2 with a tag replicatefrom postgres-1
|
||||
Then replication works from postgres-0 to postgres-2 after 30 seconds
|
||||
And there is a label with "postgres-1" in postgres-2 data directory
|
||||
|
||||
+54
-47
@@ -2,72 +2,79 @@ Feature: citus
|
||||
We should check that coordinator discovers and registers workers and clients don't have errors when worker cluster switches over
|
||||
|
||||
Scenario: check that worker cluster is registered in the coordinator
|
||||
Given I start postgres0 in citus group 0
|
||||
And I start postgres2 in citus group 1
|
||||
Then postgres0 is a leader in a group 0 after 10 seconds
|
||||
And postgres2 is a leader in a group 1 after 10 seconds
|
||||
When I start postgres1 in citus group 0
|
||||
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 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
|
||||
Given I start postgres-0 in citus group 0
|
||||
And I start postgres-2 in citus group 1
|
||||
Then postgres-0 is a leader in a group 0 after 10 seconds
|
||||
And postgres-2 is a leader in a group 1 after 10 seconds
|
||||
When I start postgres-1 in citus group 0
|
||||
And I start postgres-3 in citus group 1
|
||||
Then replication works from postgres-0 to postgres-1 after 15 seconds
|
||||
Then replication works from postgres-2 to postgres-3 after 15 seconds
|
||||
And postgres-0 is registered in the postgres-0 as the primary in group 0 after 5 seconds
|
||||
And postgres-1 is registered in the postgres-0 as the secondary in group 0 after 5 seconds
|
||||
And postgres-2 is registered in the postgres-0 as the primary in group 1 after 5 seconds
|
||||
And postgres-3 is registered in the postgres-0 as the secondary in group 1 after 5 seconds
|
||||
|
||||
Scenario: coordinator failover updates pg_dist_node
|
||||
Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force
|
||||
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
|
||||
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
|
||||
Given I run patronictl.py failover batman --group 0 --candidate postgres-1 --force
|
||||
Then postgres-1 role is the primary after 10 seconds
|
||||
And "members/postgres-0" key in a group 0 in DCS has state=running after 15 seconds
|
||||
And replication works from postgres-1 to postgres-0 after 15 seconds
|
||||
And postgres-1 is registered in the postgres-2 as the primary in group 0 after 5 seconds
|
||||
And postgres-0 is registered in the postgres-2 as the secondary in group 0 after 15 seconds
|
||||
And "sync" key in a group 0 in DCS has sync_standby=postgres-0 after 15 seconds
|
||||
When I run patronictl.py switchover batman --group 0 --candidate postgres-0 --force
|
||||
Then postgres-0 role is the primary after 10 seconds
|
||||
And replication works from postgres-0 to postgres-1 after 15 seconds
|
||||
And postgres-0 is registered in the postgres-2 as the primary in group 0 after 5 seconds
|
||||
And postgres-1 is registered in the postgres-2 as the secondary in group 0 after 15 seconds
|
||||
And "sync" key in a group 0 in DCS has sync_standby=postgres-1 after 15 seconds
|
||||
|
||||
Scenario: worker switchover doesn't break client queries on the coordinator
|
||||
Given I create a distributed table on postgres0
|
||||
And I start a thread inserting data on postgres0
|
||||
Given I create a distributed table on postgres-0
|
||||
And I start a thread inserting data on postgres-0
|
||||
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 postgres-3 role is the primary after 10 seconds
|
||||
And "members/postgres-2" key in a group 1 in DCS has state=running after 15 seconds
|
||||
And replication works from postgres-3 to postgres-2 after 15 seconds
|
||||
And postgres-3 is registered in the postgres-0 as the primary in group 1 after 5 seconds
|
||||
And postgres-2 is registered in the postgres-0 as the secondary in group 1 after 15 seconds
|
||||
And "sync" key in a group 1 in DCS has sync_standby=postgres-2 after 15 seconds
|
||||
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 postgres-2 role is the primary after 10 seconds
|
||||
And replication works from postgres-2 to postgres-3 after 15 seconds
|
||||
And postgres-2 is registered in the postgres-0 as the primary in group 1 after 5 seconds
|
||||
And postgres-3 is registered in the postgres-0 as the secondary in group 1 after 15 seconds
|
||||
And "sync" key in a group 1 in DCS has sync_standby=postgres-3 after 15 seconds
|
||||
And a thread is still alive
|
||||
When I stop a thread
|
||||
Then a distributed table on postgres0 has expected rows
|
||||
Then a distributed table on postgres-0 has expected rows
|
||||
|
||||
Scenario: worker primary restart doesn't break client queries on the coordinator
|
||||
Given I cleanup a distributed table on postgres0
|
||||
And I start a thread inserting data on postgres0
|
||||
When I run patronictl.py restart batman postgres2 --group 1 --force
|
||||
Given I cleanup a distributed table on postgres-0
|
||||
And I start a thread inserting data on postgres-0
|
||||
When I run patronictl.py restart batman postgres-2 --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 postgres-2 role is the primary after 10 seconds
|
||||
And replication works from postgres-2 to postgres-3 after 15 seconds
|
||||
And postgres-2 is registered in the postgres-0 as the primary in group 1 after 5 seconds
|
||||
And postgres-3 is registered in the postgres-0 as the secondary in group 1 after 15 seconds
|
||||
And a thread is still alive
|
||||
When I stop a thread
|
||||
Then a distributed table on postgres0 has expected rows
|
||||
Then a distributed table on postgres-0 has expected rows
|
||||
|
||||
Scenario: check that in-flight transaction is rolled back after timeout when other workers need to change pg_dist_node
|
||||
Given I start postgres4 in citus group 2
|
||||
Then postgres4 is a leader in a group 2 after 10 seconds
|
||||
And "members/postgres4" key in a group 2 in DCS has role=master after 3 seconds
|
||||
Given I start postgres-4 in citus group 2
|
||||
Then postgres-4 is a leader in a group 2 after 10 seconds
|
||||
And "members/postgres-4" key in a group 2 in DCS has role=primary after 3 seconds
|
||||
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"
|
||||
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 after 5 seconds
|
||||
When I run patronictl.py restart batman postgres2 --group 1 --force
|
||||
Then postgres-4 is registered in the postgres-2 as the primary in group 2 after 5 seconds
|
||||
When I shut down postgres-4
|
||||
Then there is a transaction in progress on postgres-0 changing pg_dist_node after 5 seconds
|
||||
When I run patronictl.py restart batman postgres-2 --group 1 --force
|
||||
Then a transaction finishes in 20 seconds
|
||||
|
||||
@@ -2,16 +2,16 @@ Feature: custom bootstrap
|
||||
We should check that patroni can bootstrap a new cluster from a backup
|
||||
|
||||
Scenario: clone existing cluster using pg_basebackup
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
When I add the table foo to postgres0
|
||||
And I start postgres1 in a cluster batman1 as a clone of postgres0
|
||||
Then postgres1 is a leader of batman1 after 10 seconds
|
||||
Then table foo is present on postgres1 after 10 seconds
|
||||
Given I start postgres-0
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
When I add the table foo to postgres-0
|
||||
And I start postgres-1 in a cluster batman1 as a clone of postgres-0
|
||||
Then postgres-1 is a leader of batman1 after 10 seconds
|
||||
Then table foo is present on postgres-1 after 10 seconds
|
||||
|
||||
Scenario: make a backup and do a restore into a new cluster
|
||||
Given I add the table bar to postgres1
|
||||
And I do a backup of postgres1
|
||||
When I start postgres2 in a cluster batman2 from backup
|
||||
Then postgres2 is a leader of batman2 after 30 seconds
|
||||
And table bar is present on postgres2 after 10 seconds
|
||||
Given I add the table bar to postgres-1
|
||||
And I do a backup of postgres-1
|
||||
When I start postgres-2 in a cluster batman2 from backup
|
||||
Then postgres-2 is a leader of batman2 after 30 seconds
|
||||
And table bar is present on postgres-2 after 10 seconds
|
||||
|
||||
@@ -2,16 +2,16 @@ Feature: dcs failsafe mode
|
||||
We should check the basic dcs failsafe mode functioning
|
||||
|
||||
Scenario: check failsafe mode can be successfully enabled
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
Given I start postgres-0
|
||||
And postgres-0 is a leader after 10 seconds
|
||||
Then "config" key in DCS has ttl=30 after 10 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 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
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres-0 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"}},"slots":{"dcs_slot_1": null,"postgres0":null}}
|
||||
And I receive a response postgres-0 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"}},"slots":{"dcs_slot_1": null,"postgres_0":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
|
||||
@@ -20,97 +20,99 @@ Feature: dcs failsafe mode
|
||||
Scenario: check one-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
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
And postgres-0 role is the primary after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check new replica isn't promoted when leader is down and DCS is up
|
||||
Given DCS is up
|
||||
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
|
||||
Then postgres1 role is the replica after 12 seconds
|
||||
When I do a backup of postgres-0
|
||||
And I shut down postgres-0
|
||||
When I start postgres-1 in a cluster batman from backup with no_leader
|
||||
Then postgres-1 role is the replica after 12 seconds
|
||||
|
||||
Scenario: check leader and replica are both in /failsafe key after leader is back
|
||||
Given I start postgres0
|
||||
And I start postgres1
|
||||
Then "members/postgres0" key in DCS has state=running after 10 seconds
|
||||
And "members/postgres1" key in DCS has state=running after 2 seconds
|
||||
And Response on GET http://127.0.0.1:8009/failsafe contains postgres1 after 10 seconds
|
||||
Given I start postgres-0
|
||||
And I start postgres-1
|
||||
Then "members/postgres-0" key in DCS has state=running after 10 seconds
|
||||
And "members/postgres-1" key in DCS has state=running after 2 seconds
|
||||
And Response on GET http://127.0.0.1:8009/failsafe contains postgres-1 after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/failsafe
|
||||
Then I receive a response code 200
|
||||
And I receive a response postgres0 http://127.0.0.1:8008/patroni
|
||||
And I receive a response postgres1 http://127.0.0.1:8009/patroni
|
||||
And I receive a response postgres-0 http://127.0.0.1:8008/patroni
|
||||
And I receive a response postgres-1 http://127.0.0.1:8009/patroni
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
@pg110000
|
||||
Scenario: check leader and replica are functioning while DCS is down
|
||||
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
|
||||
Given I get all changes from physical slot dcs_slot_1 on postgres-0
|
||||
Then physical slot dcs_slot_1 is in sync between postgres-0 and postgres-1 after 10 seconds
|
||||
And logical slot dcs_slot_0 is in sync between postgres-0 and postgres-1 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
|
||||
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
|
||||
Then postgres-0 role is the primary after 10 seconds
|
||||
And postgres-1 role is the replica after 2 seconds
|
||||
And replication works from postgres-0 to postgres-1 after 10 seconds
|
||||
When I get all changes from logical slot dcs_slot_0 on postgres-0
|
||||
And I get all changes from physical slot dcs_slot_1 on postgres-0
|
||||
Then logical slot dcs_slot_0 is in sync between postgres-0 and postgres-1 after 20 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres-0 and postgres-1 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
|
||||
Then postgres0 role is the replica after 12 seconds
|
||||
And I kill postgres-1
|
||||
And I kill postmaster on postgres-1
|
||||
Then postgres-0 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 kill postgres0
|
||||
And I shut down postmaster on postgres0
|
||||
Given I kill postgres-0
|
||||
And I shut down postmaster on postgres-0
|
||||
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
|
||||
When I start postgres-1
|
||||
Then "members/postgres-1" key in DCS has state=running after 10 seconds
|
||||
And postgres-1 role is the primary after 25 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
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
|
||||
Given I start postgres-0
|
||||
And I configure and start postgres-2 with a tag replicatefrom postgres-0
|
||||
Then "members/postgres-2" key in DCS has state=running after 10 seconds
|
||||
And "members/postgres-0" key in DCS has state=running after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres-2 after 10 seconds
|
||||
And replication works from postgres-1 to postgres-0 after 10 seconds
|
||||
And replication works from postgres-1 to postgres-2 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
@pg110000
|
||||
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
|
||||
Then logical slot dcs_slot_2 is in sync between postgres-1 and postgres-0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres-1 and postgres-2 after 20 seconds
|
||||
When I get all changes from physical slot dcs_slot_1 on postgres-1
|
||||
Then physical slot dcs_slot_1 is in sync between postgres-1 and postgres-0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres-1 and postgres-2 after 10 seconds
|
||||
And physical slot postgres_0 is in sync between postgres-1 and postgres-2 after 10 seconds
|
||||
And physical slot postgres_2 is in sync between postgres-0 and postgres-1 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: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
|
||||
Then postgres-1 role is the primary after 10 seconds
|
||||
And postgres-0 role is the replica after 2 seconds
|
||||
And postgres-2 role is the replica after 2 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
@pg110000
|
||||
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
|
||||
Given replication works from postgres-1 to postgres-0 after 10 seconds
|
||||
And replication works from postgres-1 to postgres-2 after 10 seconds
|
||||
When I get all changes from logical slot dcs_slot_2 on postgres-1
|
||||
And I get all changes from physical slot dcs_slot_1 on postgres-1
|
||||
Then logical slot dcs_slot_2 is in sync between postgres-1 and postgres-0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres-1 and postgres-2 after 20 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres-1 and postgres-0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres-1 and postgres-2 after 10 seconds
|
||||
And physical slot postgres_0 is in sync between postgres-1 and postgres-2 after 10 seconds
|
||||
And physical slot postgres_2 is in sync between postgres-0 and postgres-1 after 10 seconds
|
||||
|
||||
+54
-26
@@ -1,9 +1,8 @@
|
||||
import abc
|
||||
import datetime
|
||||
import glob
|
||||
import os
|
||||
import json
|
||||
import psutil
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
@@ -13,11 +12,14 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import psutil
|
||||
import yaml
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from patroni.request import PatroniRequest
|
||||
|
||||
|
||||
@@ -52,6 +54,8 @@ class AbstractController(abc.ABC):
|
||||
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
|
||||
self._handle = self._start()
|
||||
|
||||
if max_wait_limit < 0:
|
||||
return
|
||||
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)
|
||||
@@ -216,6 +220,8 @@ class PatroniController(AbstractController):
|
||||
'host replication replicator all md5',
|
||||
'host all all all md5'
|
||||
]
|
||||
if isinstance(self._context.dcs_ctl, KubernetesController):
|
||||
config['kubernetes'] = {'bootstrap_labels': {'foo': 'bar'}}
|
||||
|
||||
if self._context.postgres_supports_ssl and self._context.certfile:
|
||||
config['postgresql']['parameters'].update({
|
||||
@@ -256,10 +262,18 @@ class PatroniController(AbstractController):
|
||||
'parameters': {
|
||||
'wal_keep_segments': 100,
|
||||
'archive_mode': 'on',
|
||||
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||
+ ' --mode archive '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self._work_directory, 'data', 'wal_archive'))
|
||||
'archive_command':
|
||||
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||
+ ' --mode archive '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self._work_directory, 'data',
|
||||
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/'),
|
||||
'restore_command':
|
||||
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||
+ ' --mode restore '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self._work_directory, 'data',
|
||||
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,6 +504,7 @@ class AbstractEtcdController(AbstractDcsController):
|
||||
|
||||
def _is_running(self):
|
||||
from patroni.dcs.etcd import DnsCachingResolver
|
||||
|
||||
# if etcd is running, but we didn't start it
|
||||
try:
|
||||
self._client = self._client_cls({'host': 'localhost', 'port': 2379, 'retry_timeout': 30,
|
||||
@@ -646,6 +661,10 @@ class KubernetesController(AbstractExternalDcsController):
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def pod_labels(self, name):
|
||||
pod = self._api.read_namespaced_pod(name, self._namespace)
|
||||
return pod.metadata.labels or {}
|
||||
|
||||
def query(self, key, scope='batman', group=None):
|
||||
if key.startswith('members/'):
|
||||
pod = self._api.read_namespaced_pod(key[8:], self._namespace)
|
||||
@@ -859,7 +878,7 @@ class PatroniPoolController(object):
|
||||
os.makedirs(feature_dir)
|
||||
self._output_dir = feature_dir
|
||||
|
||||
def clone(self, from_name, cluster_name, to_name):
|
||||
def clone(self, from_name, cluster_name, to_name, long_running=False):
|
||||
f = self._processes[from_name]
|
||||
custom_config = {
|
||||
'scope': cluster_name,
|
||||
@@ -867,7 +886,8 @@ class PatroniPoolController(object):
|
||||
'method': 'pg_basebackup',
|
||||
'pg_basebackup': {
|
||||
'command': " ".join(self.BACKUP_SCRIPT
|
||||
+ ['--walmethod=stream', '--dbname="{0}"'.format(f.backup_source)])
|
||||
+ ['--walmethod=stream', f'--dbname="{f.backup_source}"',
|
||||
f'--sleep {5 if long_running else 0}'])
|
||||
},
|
||||
'dcs': {
|
||||
'postgresql': {
|
||||
@@ -885,17 +905,21 @@ class PatroniPoolController(object):
|
||||
.format(os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
|
||||
},
|
||||
'authentication': {
|
||||
'superuser': {'password': 'zalando1'},
|
||||
'superuser': {'password': 'patroni1'},
|
||||
'replication': {'password': 'rep-pass1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.start(to_name, custom_config=custom_config)
|
||||
kwargs = {'custom_config': custom_config}
|
||||
if long_running:
|
||||
kwargs['max_wait_limit'] = -1
|
||||
self.start(to_name, **kwargs)
|
||||
|
||||
def backup_restore_config(self, params=None):
|
||||
def backup_restore_config(self, params=None, long_running=False):
|
||||
return {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT
|
||||
+ ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')).replace('\\', '/'),
|
||||
+ ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')
|
||||
+ f' --sleep {5 if long_running else 0}').replace('\\', '/'),
|
||||
'test-argument': 'test-value', # test config mapping approach on custom bootstrap/replica creation
|
||||
**(params or {}),
|
||||
}
|
||||
@@ -917,7 +941,7 @@ class PatroniPoolController(object):
|
||||
},
|
||||
'postgresql': {
|
||||
'authentication': {
|
||||
'superuser': {'password': 'zalando2'},
|
||||
'superuser': {'password': 'patroni2'},
|
||||
'replication': {'password': 'rep-pass2'}
|
||||
}
|
||||
}
|
||||
@@ -928,11 +952,6 @@ class PatroniPoolController(object):
|
||||
custom_config = {
|
||||
'scope': cluster_name,
|
||||
'postgresql': {
|
||||
'recovery_conf': {
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
|
||||
+ '--dirname {} --filename %f --pathname %p')
|
||||
.format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||
},
|
||||
'create_replica_methods': ['no_leader_bootstrap'],
|
||||
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
|
||||
}
|
||||
@@ -1117,12 +1136,14 @@ def before_feature(context, feature):
|
||||
elif feature.name == 'citus':
|
||||
lib = subprocess.check_output(['pg_config', '--pkglibdir']).decode('utf-8').strip()
|
||||
if not os.path.exists(os.path.join(lib, 'citus.so')):
|
||||
return feature.skip("Citus extenstion isn't available")
|
||||
return feature.skip("Citus extension isn't available")
|
||||
elif feature.name == 'bootstrap labels' and context.dcs_ctl.name() != 'kubernetes':
|
||||
feature.skip("Tested only on Kubernetes")
|
||||
context.pctl.create_and_set_output_directory(feature.name)
|
||||
|
||||
|
||||
def after_feature(context, feature):
|
||||
""" send SIGCONT to a dcs if neccessary,
|
||||
""" send SIGCONT to a dcs if necessary,
|
||||
stop all Patronis remove their data directory and cleanup the keys in etcd """
|
||||
context.dcs_ctl.stop_outage()
|
||||
context.pctl.stop_all()
|
||||
@@ -1147,11 +1168,18 @@ def after_feature(context, feature):
|
||||
|
||||
|
||||
def before_scenario(context, scenario):
|
||||
if 'slot-advance' in scenario.effective_tags:
|
||||
for p in context.pctl._processes.values():
|
||||
if p._conn and p._conn.server_version < 110000:
|
||||
scenario.skip('pg_replication_slot_advance() is not supported on {0}'.format(p._conn.server_version))
|
||||
break
|
||||
for tag in scenario.effective_tags:
|
||||
if tag.startswith('pg') and 6 < len(tag) < 9:
|
||||
try:
|
||||
ver = int(tag[2:])
|
||||
except Exception:
|
||||
ver = 0
|
||||
if not ver:
|
||||
continue
|
||||
for p in context.pctl._processes.values():
|
||||
if p._conn and p._conn.server_version < ver:
|
||||
scenario.skip('not supported on {0}'.format(p._conn.server_version))
|
||||
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':
|
||||
|
||||
@@ -1,61 +1,66 @@
|
||||
Feature: ignored slots
|
||||
Scenario: check ignored slots aren't removed on failover/switchover
|
||||
Given I start postgres1
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
Given I start postgres-1
|
||||
Then postgres-1 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"ignore_slots": [{"name": "unmanaged_slot_0", "database": "postgres", "plugin": "test_decoding", "type": "logical"}, {"name": "unmanaged_slot_1", "database": "postgres", "plugin": "test_decoding"}, {"name": "unmanaged_slot_2", "database": "postgres"}, {"name": "unmanaged_slot_3"}], "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8009/config contains ignore_slots after 10 seconds
|
||||
And Response on GET http://127.0.0.1:8009/patroni contains pending_restart after 10 seconds
|
||||
# Make sure the wal_level has been changed.
|
||||
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 10 seconds
|
||||
When I run patronictl.py restart batman postgres-1 --force
|
||||
Then "members/postgres-1" key in DCS has role=primary after 10 seconds
|
||||
# Make sure Patroni has finished telling Postgres it should be accepting writes.
|
||||
And postgres1 role is the primary after 20 seconds
|
||||
And postgres-1 role is the primary after 20 seconds
|
||||
# 1. Create our test logical replication slot.
|
||||
# Test that ny subset of attributes in the ignore slots matcher is enough to match a slot
|
||||
# by using 3 different slots.
|
||||
When I create a logical replication slot unmanaged_slot_0 on postgres1 with the test_decoding plugin
|
||||
And I create a logical replication slot unmanaged_slot_1 on postgres1 with the test_decoding plugin
|
||||
And I create a logical replication slot unmanaged_slot_2 on postgres1 with the test_decoding plugin
|
||||
And I create a logical replication slot unmanaged_slot_3 on postgres1 with the test_decoding plugin
|
||||
And I create a logical replication slot dummy_slot on postgres1 with the test_decoding plugin
|
||||
When I create a logical replication slot unmanaged_slot_0 on postgres-1 with the test_decoding plugin
|
||||
And I create a logical replication slot unmanaged_slot_1 on postgres-1 with the test_decoding plugin
|
||||
And I create a logical replication slot unmanaged_slot_2 on postgres-1 with the test_decoding plugin
|
||||
And I create a logical replication slot unmanaged_slot_3 on postgres-1 with the test_decoding plugin
|
||||
And I create a logical replication slot dummy_slot on postgres-1 with the test_decoding plugin
|
||||
# It seems like it'd be obvious that these slots exist since we just created them,
|
||||
# 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 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
|
||||
Then postgres-1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
|
||||
When I start postgres0
|
||||
Then "members/postgres0" key in DCS has role=replica after 10 seconds
|
||||
And postgres0 role is the secondary after 20 seconds
|
||||
When I start postgres-0
|
||||
Then "members/postgres-0" key in DCS has role=replica after 10 seconds
|
||||
And postgres-0 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 10 seconds
|
||||
And replication works from postgres-1 to postgres-0 after 20 seconds
|
||||
When I shut down postgres-1
|
||||
Then "members/postgres-0" key in DCS has role=primary 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 10 seconds
|
||||
When I start postgres-1
|
||||
Then postgres-1 role is the secondary after 20 seconds
|
||||
And "members/postgres-1" 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 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
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 does not have a replication slot named dummy_slot
|
||||
|
||||
# 3. After a failover the server (now a primary) still has the slot.
|
||||
When I shut down postgres0
|
||||
Then "members/postgres1" key in DCS has role=master after 10 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin 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 shut down postgres-0
|
||||
Then "members/postgres-1" key in DCS has role=primary after 10 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres-1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
|
||||
@pg170000
|
||||
Scenario: check that logical slots with failover are not removed by Patroni
|
||||
Given I create a logical failover slot test17 on postgres-1 with the pgoutput plugin
|
||||
When I run patronictl.py restart batman postgres-1 --force
|
||||
Then postgres-1 has a logical replication slot named test17 with the pgoutput plugin after 2 seconds
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
Feature: nostream node
|
||||
|
||||
Scenario: check nostream node is recovering from archive
|
||||
When I start postgres-0
|
||||
And I configure and start postgres-1 with a tag nostream true
|
||||
Then "members/postgres-1" key in DCS has replication_state=in archive recovery after 10 seconds
|
||||
And replication works from postgres-0 to postgres-1 after 30 seconds
|
||||
|
||||
@pg110000
|
||||
Scenario: check permanent logical replication slots are not copied
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}, "slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Then I receive a response code 200
|
||||
When I run patronictl.py restart batman postgres-0 --force
|
||||
Then postgres-0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
||||
When I configure and start postgres-2 with a tag replicatefrom postgres-1
|
||||
Then "members/postgres-2" key in DCS has replication_state=streaming after 10 seconds
|
||||
And postgres-1 does not have a replication slot named test_logical
|
||||
And postgres-2 does not have a replication slot named test_logical
|
||||
|
||||
@pg110000
|
||||
Scenario: check that slots are written to the /status key
|
||||
Given "status" key in DCS has postgres_0 in slots
|
||||
And "status" key in DCS has postgres_2 in slots
|
||||
And "status" key in DCS has test_logical in slots
|
||||
And "status" key in DCS has test_logical in slots
|
||||
And "status" key in DCS does not have postgres_1 in slots
|
||||
@@ -2,12 +2,12 @@ Feature: patroni api
|
||||
We should check that patroni correctly responds to valid and not-valid API requests.
|
||||
|
||||
Scenario: check API requests on a stand-alone server
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
Given I start postgres-0
|
||||
And postgres-0 is a leader 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 state running
|
||||
And I receive a response role master
|
||||
And I receive a response role primary
|
||||
When I issue a GET request to http://127.0.0.1:8008/standby_leader
|
||||
Then I receive a response code 503
|
||||
When I issue a GET request to http://127.0.0.1:8008/health
|
||||
@@ -17,10 +17,10 @@ Scenario: check API requests on a stand-alone server
|
||||
When I issue a POST request to http://127.0.0.1:8008/reinitialize with {"force": true}
|
||||
Then I receive a response code 503
|
||||
And I receive a response text I am the leader, can not reinitialize
|
||||
When I run patronictl.py switchover batman --master postgres0 --force
|
||||
When I run patronictl.py switchover batman --primary postgres-0 --force
|
||||
Then I receive a response returncode 1
|
||||
And I receive a response output "Error: No candidates found to switchover to"
|
||||
When I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres0"}
|
||||
When I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres-0"}
|
||||
Then I receive a response code 412
|
||||
And I receive a response text switchover is not possible: cluster does not have members except leader
|
||||
When I issue an empty POST request to http://127.0.0.1:8008/failover
|
||||
@@ -30,7 +30,7 @@ Scenario: check API requests on a stand-alone server
|
||||
And I receive a response text "Failover could be performed only to a specific candidate"
|
||||
|
||||
Scenario: check local configuration reload
|
||||
Given I add tag new_tag new_value to postgres0 config
|
||||
Given I add tag new_tag new_value to postgres-0 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8008/reload
|
||||
Then I receive a response code 202
|
||||
|
||||
@@ -58,43 +58,43 @@ Scenario: check the scheduled restart
|
||||
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 postgres-0 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Given I start postgres1
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
Given I start postgres-1
|
||||
Then replication works from postgres-0 to postgres-1 after 20 seconds
|
||||
When I run patronictl.py pause batman
|
||||
Then I receive a response returncode 0
|
||||
When I kill postmaster on postgres1
|
||||
When I kill postmaster on postgres-1
|
||||
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
|
||||
And "members/postgres-1" key in DCS has state=stopped after 10 seconds
|
||||
When I run patronictl.py restart batman postgres-1 --force
|
||||
Then I receive a response returncode 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
Then replication works from postgres-0 to postgres-1 after 20 seconds
|
||||
And I sleep for 2 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
And I receive a response role replica
|
||||
When I run patronictl.py reinit batman postgres1 --force --wait
|
||||
When I run patronictl.py reinit batman postgres-1 --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
|
||||
And I receive a response output "Success: reinitialize for member postgres-1"
|
||||
And postgres-1 role is the secondary after 30 seconds
|
||||
And replication works from postgres-0 to postgres-1 after 20 seconds
|
||||
When I run patronictl.py restart batman postgres-0 --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
|
||||
And I receive a response output "Success: restart on member postgres-0"
|
||||
And postgres-0 role is the primary after 5 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"}
|
||||
Given I issue a POST request to http://127.0.0.1:8008/switchover with {"leader": "postgres-0", "candidate": "postgres-1"}
|
||||
Then I receive a response code 200
|
||||
And postgres1 is a leader after 5 seconds
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
And postgres0 role is the secondary after 10 seconds
|
||||
And replication works from postgres1 to postgres0 after 20 seconds
|
||||
And "members/postgres0" key in DCS has state=running after 10 seconds
|
||||
And postgres-1 is a leader after 5 seconds
|
||||
And postgres-1 role is the primary after 10 seconds
|
||||
And postgres-0 role is the secondary after 10 seconds
|
||||
And replication works from postgres-1 to postgres-0 after 20 seconds
|
||||
And "members/postgres-0" key in DCS has state=running after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/primary
|
||||
Then I receive a response code 503
|
||||
When I issue a GET request to http://127.0.0.1:8008/replica
|
||||
@@ -105,18 +105,18 @@ Scenario: check the switchover via the API in the pause mode
|
||||
Then I receive a response code 503
|
||||
|
||||
Scenario: check the scheduled switchover
|
||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
|
||||
Given I issue a scheduled switchover from postgres-1 to postgres-0 in 10 seconds
|
||||
Then I receive a response returncode 1
|
||||
And I receive a response output "Can't schedule switchover in the paused state"
|
||||
When I run patronictl.py resume batman
|
||||
Then I receive a response returncode 0
|
||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
|
||||
Given I issue a scheduled switchover from postgres-1 to postgres-0 in 10 seconds
|
||||
Then I receive a response returncode 0
|
||||
And postgres0 is a leader after 20 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
And postgres1 role is the secondary after 10 seconds
|
||||
And replication works from postgres0 to postgres1 after 25 seconds
|
||||
And "members/postgres1" key in DCS has state=running after 10 seconds
|
||||
And postgres-0 is a leader after 20 seconds
|
||||
And postgres-0 role is the primary after 10 seconds
|
||||
And postgres-1 role is the secondary after 10 seconds
|
||||
And replication works from postgres-0 to postgres-1 after 25 seconds
|
||||
And "members/postgres-1" key in DCS has state=running after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/primary
|
||||
Then I receive a response code 200
|
||||
When I issue a GET request to http://127.0.0.1:8008/replica
|
||||
|
||||
@@ -1,75 +1,86 @@
|
||||
Feature: permanent slots
|
||||
Scenario: check that physical permanent slots are created
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
Given I start postgres-0
|
||||
Then postgres-0 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"}}}
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_physical":0,"postgres_3":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
|
||||
When I start postgres-1
|
||||
And I configure and start postgres-2 with a tag nofailover true
|
||||
And I configure and start postgres-3 with a tag replicatefrom postgres-2
|
||||
Then postgres-0 has a physical replication slot named test_physical after 10 seconds
|
||||
And postgres-0 has a physical replication slot named postgres_1 after 10 seconds
|
||||
And postgres-0 has a physical replication slot named postgres_2 after 10 seconds
|
||||
And postgres-2 has a physical replication slot named postgres_3 after 10 seconds
|
||||
And postgres-2 does not have a replication slot named test_physical
|
||||
|
||||
@slot-advance
|
||||
@pg110000
|
||||
Scenario: check that logical permanent slots are created
|
||||
Given I run patronictl.py restart batman postgres0 --force
|
||||
Given I run patronictl.py restart batman postgres-0 --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
|
||||
Then postgres-0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
@pg110000
|
||||
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
|
||||
Given postgres-1 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 postgres-0 and postgres-1 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres-0 and postgres-3 after 10 seconds
|
||||
And postgres-1 has a physical replication slot named test_physical after 2 seconds
|
||||
And postgres-2 does not have a replication slot named test_logical
|
||||
And postgres-3 has a physical replication slot named test_physical after 2 seconds
|
||||
|
||||
@slot-advance
|
||||
@pg110000
|
||||
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
|
||||
Given postgres-0 has a physical replication slot named postgres_3 after 2 seconds
|
||||
And postgres-1 has a physical replication slot named postgres_0 after 2 seconds
|
||||
And postgres-1 has a physical replication slot named postgres_2 after 2 seconds
|
||||
And postgres-1 has a physical replication slot named postgres_3 after 2 seconds
|
||||
And postgres-2 does not have a replication slot named postgres_0
|
||||
And postgres-2 does not have a replication slot named postgres_1
|
||||
And postgres-2 has a physical replication slot named postgres_3 after 2 seconds
|
||||
And postgres-3 has a physical replication slot named postgres_0 after 2 seconds
|
||||
And postgres-3 has a physical replication slot named postgres_1 after 2 seconds
|
||||
And postgres-3 has a physical replication slot named postgres_2 after 2 seconds
|
||||
|
||||
@slot-advance
|
||||
@pg110000
|
||||
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
|
||||
Given I add the table replicate_me to postgres-0
|
||||
When I get all changes from logical slot test_logical on postgres-0
|
||||
And I get all changes from physical slot test_physical on postgres-0
|
||||
Then Logical slot test_logical is in sync between postgres-0 and postgres-1 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres-0 and postgres-1 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres-0 and postgres-3 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres-0 and postgres-3 after 10 seconds
|
||||
And Physical slot postgres_1 is in sync between postgres-0 and postgres-3 after 10 seconds
|
||||
And Physical slot postgres_3 is in sync between postgres-2 and postgres-0 after 20 seconds
|
||||
And Physical slot postgres_3 is in sync between postgres-2 and postgres-1 after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that only permanent slots are written to the /status key
|
||||
@pg110000
|
||||
Scenario: check that permanent slots and member 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
|
||||
And "status" key in DCS has postgres_0 in slots
|
||||
And "status" key in DCS has postgres_1 in slots
|
||||
And "status" key in DCS has postgres_2 in slots
|
||||
And "status" key in DCS has postgres_3 in slots
|
||||
|
||||
@pg110000
|
||||
Scenario: check that only non-permanent member slots are written to the retain_slots in /status key
|
||||
Given "status" key in DCS has postgres_0 in retain_slots
|
||||
And "status" key in DCS has postgres_1 in retain_slots
|
||||
And "status" key in DCS has postgres_2 in retain_slots
|
||||
And "status" key in DCS does not have postgres_3 in retain_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
|
||||
Given I shut down postgres-3
|
||||
And I shut down postgres-2
|
||||
And I shut down postgres-0
|
||||
Then postgres-1 has a physical replication slot named test_physical after 10 seconds
|
||||
And postgres-1 has a physical replication slot named postgres_0 after 10 seconds
|
||||
And postgres-1 has a physical replication slot named postgres_3 after 10 seconds
|
||||
When I start postgres-0
|
||||
Then postgres-0 role is the replica after 20 seconds
|
||||
And physical replication slot named postgres_1 on postgres-0 has no xmin value after 10 seconds
|
||||
# postgres_2 and postgres_3 slots are retained, but postgres_2 will still have xmin value :(
|
||||
And postgres-0 has a physical replication slot named postgres_2 after 10 seconds
|
||||
And postgres-0 has a physical replication slot named postgres_3 after 10 seconds
|
||||
|
||||
@@ -2,22 +2,38 @@ 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 I sleep for 5 seconds
|
||||
Then postgres1 role is the secondary after 10 seconds
|
||||
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
|
||||
Given I start postgres0
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
Given I configure and start postgres-0 with a tag failover_priority 1
|
||||
And I configure and start postgres-1 with a tag failover_priority 0
|
||||
Then replication works from postgres-0 to postgres-1 after 20 seconds
|
||||
When I shut down postgres-0
|
||||
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres-1 patroni log after 5 seconds
|
||||
Then postgres-1 role is the secondary after 10 seconds
|
||||
When I start postgres-0
|
||||
Then postgres-0 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
|
||||
And I sleep for 5 seconds
|
||||
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
|
||||
Given I configure and start postgres-2 with a tag failover_priority 1
|
||||
And I configure and start postgres-3 with a tag failover_priority 2
|
||||
Then replication works from postgres-0 to postgres-2 after 20 seconds
|
||||
And replication works from postgres-0 to postgres-3 after 20 seconds
|
||||
When I shut down postgres-0
|
||||
Then postgres-3 role is the primary after 10 seconds
|
||||
And there is one of ["postgres-3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres-3 is ahead of my wal position"] INFO in the postgres-2 patroni log after 5 seconds
|
||||
|
||||
Scenario: check conflicting configuration handling
|
||||
When I set nofailover tag in postgres-2 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 postgres-2 patroni log after 5 seconds
|
||||
And "members/postgres-2" 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": "postgres-2"}
|
||||
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 postgres-1 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 postgres-1 patroni log after 5 seconds
|
||||
And "members/postgres-1" 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": "postgres-1"}
|
||||
Then I receive a response code 200
|
||||
And postgres-1 role is the primary after 10 seconds
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
Feature: synchronous replicas priority
|
||||
We should check that we can give nodes priority for becoming synchronous replicas
|
||||
|
||||
Scenario: check replica with sync_priority=0 does not become a synchronous replica
|
||||
Given I start postgres-0
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "synchronous_mode": true}
|
||||
Then I receive a response code 200
|
||||
When I configure and start postgres-1 with a tag sync_priority 0
|
||||
Then sync key in DCS has leader=postgres-0 after 20 seconds
|
||||
And sync key in DCS has sync_standby=None after 5 seconds
|
||||
|
||||
Scenario: check higher synchronous replicas priority is respected
|
||||
Given I configure and start postgres-2 with a tag sync_priority 1
|
||||
And I configure and start postgres-3 with a tag sync_priority 2
|
||||
Then replication works from postgres-0 to postgres-2 after 20 seconds
|
||||
And replication works from postgres-0 to postgres-3 after 20 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_node_count": 1}
|
||||
Then I receive a response code 200
|
||||
And sync key in DCS has sync_standby=postgres-3 after 10 seconds
|
||||
|
||||
|
||||
Scenario: check conflicting configuration handling
|
||||
When I set nosync tag in postgres-3 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8011/reload
|
||||
Then I receive a response code 202
|
||||
And there is one of ["Conflicting configuration between nosync: True and sync_priority: 2. Defaulting to nosync: True"] WARNING in the postgres-3 patroni log after 5 seconds
|
||||
And "members/postgres-3" key in DCS has tags={'nosync': True, 'sync_priority': '2'} after 10 seconds
|
||||
And "sync" key in DCS has sync_standby=postgres-2 after 10 seconds
|
||||
When I reset nosync tag in postgres-1 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 nosync: False and sync_priority: 0. Defaulting to nosync: False"] WARNING in the postgres-1 patroni log after 5 seconds
|
||||
And "members/postgres-1" key in DCS has tags={'nosync': False, 'sync_priority': '0'} after 10 seconds
|
||||
When I shut down postgres-2
|
||||
And "sync" key in DCS has sync_standby=postgres-1 after 3 seconds
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
Feature: quorum commit
|
||||
Check basic workfrlows when quorum commit is enabled
|
||||
|
||||
Scenario: check enable quorum commit and that the only leader promotes after restart
|
||||
Given I start postgres-0
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "synchronous_mode": "quorum"}
|
||||
Then I receive a response code 200
|
||||
And sync key in DCS has leader=postgres-0 after 20 seconds
|
||||
And sync key in DCS has quorum=0 after 2 seconds
|
||||
And synchronous_standby_names on postgres-0 is set to '_empty_str_' after 2 seconds
|
||||
When I shut down postgres-0
|
||||
And sync key in DCS has leader=postgres-0 after 2 seconds
|
||||
When I start postgres-0
|
||||
Then postgres-0 role is the primary after 10 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_mode_strict": true}
|
||||
Then synchronous_standby_names on postgres-0 is set to 'ANY 1 (*)' after 10 seconds
|
||||
|
||||
Scenario: check failover with one quorum standby
|
||||
Given I start postgres-1
|
||||
Then sync key in DCS has sync_standby=postgres-1 after 10 seconds
|
||||
And synchronous_standby_names on postgres-0 is set to 'ANY 1 ("postgres-1")' after 2 seconds
|
||||
When I shut down postgres-0
|
||||
Then postgres-1 role is the primary after 10 seconds
|
||||
And sync key in DCS has quorum=0 after 10 seconds
|
||||
Then synchronous_standby_names on postgres-1 is set to 'ANY 1 (*)' after 10 seconds
|
||||
When I start postgres-0
|
||||
Then sync key in DCS has leader=postgres-1 after 10 seconds
|
||||
Then sync key in DCS has sync_standby=postgres-0 after 10 seconds
|
||||
And synchronous_standby_names on postgres-1 is set to 'ANY 1 ("postgres-0")' after 2 seconds
|
||||
|
||||
Scenario: check behavior with three nodes and different replication factor
|
||||
Given I start postgres-2
|
||||
Then sync key in DCS has sync_standby=postgres-0,postgres-2 after 10 seconds
|
||||
And sync key in DCS has quorum=1 after 2 seconds
|
||||
And synchronous_standby_names on postgres-1 is set to 'ANY 1 ("postgres-0","postgres-2")' after 2 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_node_count": 2}
|
||||
Then sync key in DCS has quorum=0 after 10 seconds
|
||||
And synchronous_standby_names on postgres-1 is set to 'ANY 2 ("postgres-0","postgres-2")' after 2 seconds
|
||||
|
||||
Scenario: switch from quorum replication to good old multisync and back
|
||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_mode": true, "synchronous_node_count": 1}
|
||||
And I shut down postgres-0
|
||||
Then synchronous_standby_names on postgres-1 is set to '"postgres-2"' after 10 seconds
|
||||
And sync key in DCS has sync_standby=postgres-2 after 10 seconds
|
||||
Then sync key in DCS has quorum=0 after 2 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_mode": "quorum"}
|
||||
And I start postgres-0
|
||||
Then synchronous_standby_names on postgres-1 is set to 'ANY 1 ("postgres-0","postgres-2")' after 10 seconds
|
||||
And sync key in DCS has sync_standby=postgres-0,postgres-2 after 10 seconds
|
||||
Then sync key in DCS has quorum=1 after 2 seconds
|
||||
|
||||
Scenario: REST API and patronictl
|
||||
Given I run patronictl.py list batman
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Quorum Standby"
|
||||
And Status code on GET http://127.0.0.1:8008/quorum is 200 after 3 seconds
|
||||
And Status code on GET http://127.0.0.1:8010/quorum is 200 after 3 seconds
|
||||
|
||||
Scenario: nosync node is removed from voters and synchronous_standby_names
|
||||
Given I add tag nosync true to postgres-2 config
|
||||
When I issue an empty POST request to http://127.0.0.1:8010/reload
|
||||
Then I receive a response code 202
|
||||
And sync key in DCS has quorum=0 after 10 seconds
|
||||
And sync key in DCS has sync_standby=postgres-0 after 10 seconds
|
||||
And synchronous_standby_names on postgres-1 is set to 'ANY 1 ("postgres-0")' after 2 seconds
|
||||
And Status code on GET http://127.0.0.1:8010/quorum is 503 after 10 seconds
|
||||
+22
-13
@@ -2,25 +2,34 @@ 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
|
||||
Given I start postgres-0
|
||||
Then postgres-0 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 start postgres-1
|
||||
And I add the table foo to postgres-0
|
||||
Then table foo is present on postgres-1 after 20 seconds
|
||||
When I kill postmaster on postgres-0
|
||||
Then postgres-0 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 role primary
|
||||
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
|
||||
And "members/postgres-0" key in DCS has state=running after 12 seconds
|
||||
And replication works from postgres-0 to postgres-1 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
|
||||
When I kill postmaster on postgres-0
|
||||
Then postgres-1 is a leader after 10 seconds
|
||||
And postgres-1 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check crashed primary demotes after failed attempt to start
|
||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"master_start_timeout": null}
|
||||
Then I receive a response code 200
|
||||
And postgres-0 role is the replica after 10 seconds
|
||||
When I ensure postgres-1 fails to start after a failure
|
||||
When I kill postmaster on postgres-1
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
And there is a postgres-1_cb.log with "on_role_change demoted batman" in postgres-1 data directory
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Feature: standby cluster
|
||||
Scenario: prepare the cluster with logical slots
|
||||
Given I start postgres1
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
Given I start postgres-1
|
||||
Then postgres-1 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
Then I receive a response code 200
|
||||
@@ -9,63 +9,58 @@ Feature: standby cluster
|
||||
And I sleep for 3 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"test_logical": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
|
||||
Then I receive a response code 200
|
||||
And I do a backup of postgres1
|
||||
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
|
||||
And I do a backup of postgres-1
|
||||
When I start postgres-0
|
||||
Then "members/postgres-0" key in DCS has state=running after 10 seconds
|
||||
And replication works from postgres-1 to postgres-0 after 15 seconds
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains replication_state=streaming after 10 seconds
|
||||
And "members/postgres-0" key in DCS has replication_state=streaming after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
@pg110000
|
||||
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
|
||||
Given I run patronictl.py restart batman postgres-1 --force
|
||||
Then Logical slot test_logical is in sync between postgres-0 and postgres-1 after 10 seconds
|
||||
|
||||
Scenario: Detach exiting node from the cluster
|
||||
When I shut down postgres1
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And "members/postgres0" key in DCS has role=master after 3 seconds
|
||||
When I shut down postgres-1
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
And "members/postgres-0" key in DCS has role=primary after 5 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/
|
||||
Then I receive a response code 200
|
||||
|
||||
Scenario: check replication of a single table in a standby cluster
|
||||
Given I start postgres1 in a standby cluster batman1 as a clone of postgres0
|
||||
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
|
||||
Given I start postgres-1 in a standby cluster batman1 as a clone of postgres-0
|
||||
Then postgres-1 is a leader of batman1 after 10 seconds
|
||||
When I add the table foo to postgres-0
|
||||
Then table foo is present on postgres-1 after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8009/patroni contains replication_state=streaming after 10 seconds
|
||||
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
|
||||
When I issue a GET request to http://127.0.0.1:8009/standby_leader
|
||||
Then I receive a response code 200
|
||||
And I receive a response role standby_leader
|
||||
And there is a postgres1_cb.log with "on_role_change standby_leader batman1" in postgres1 data directory
|
||||
When I start postgres2 in a cluster batman1
|
||||
Then postgres2 role is the replica after 24 seconds
|
||||
And table foo is present on postgres2 after 20 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8010/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response replication_state streaming
|
||||
And postgres1 does not have a replication slot named test_logical
|
||||
And there is a postgres-1_cb.log with "on_role_change standby_leader batman1" in postgres-1 data directory
|
||||
When I start postgres-2 in a cluster batman1
|
||||
Then postgres-2 role is the replica after 24 seconds
|
||||
And postgres-2 is replicating from postgres-1 after 10 seconds
|
||||
And table foo is present on postgres-2 after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8010/patroni contains replication_state=streaming after 10 seconds
|
||||
And postgres-1 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
|
||||
And postgres-1 is replicating from postgres-2 after 32 seconds
|
||||
And there is a postgres-2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres-2 data directory
|
||||
|
||||
Scenario: check failover
|
||||
When I kill postgres2
|
||||
And I kill postmaster on postgres2
|
||||
Then postgres1 is replicating from postgres0 after 32 seconds
|
||||
When I kill postgres-2
|
||||
And I kill postmaster on postgres-2
|
||||
Then postgres-1 is replicating from postgres-0 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 receive a response role standby_leader
|
||||
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
|
||||
And replication works from postgres-0 to postgres-1 after 15 seconds
|
||||
And there is a postgres-1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres-1 data directory
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import json
|
||||
import patroni.psycopg as pg
|
||||
|
||||
from behave import step, then
|
||||
from time import sleep, time
|
||||
|
||||
import parse
|
||||
|
||||
@step('I start {name:w}')
|
||||
from behave import register_type, step, then
|
||||
|
||||
import patroni.psycopg as pg
|
||||
|
||||
|
||||
@parse.with_pattern(r'[a-z][a-z0-9_\-]*[a-z0-9]')
|
||||
def parse_name(text):
|
||||
return text
|
||||
|
||||
|
||||
register_type(name=parse_name)
|
||||
|
||||
|
||||
@step('I start {name:name}')
|
||||
def start_patroni(context, name):
|
||||
return context.pctl.start(name)
|
||||
|
||||
|
||||
@step('I start duplicate {name:w} on port {port:d}')
|
||||
@step('I start duplicate {name:name} on port {port:d}')
|
||||
def start_duplicate_patroni(context, name, port):
|
||||
config = {
|
||||
"name": name,
|
||||
@@ -26,47 +38,51 @@ def start_duplicate_patroni(context, name, port):
|
||||
"No error was raised by duplicate start of {0} ".format(name)
|
||||
|
||||
|
||||
@step('I shut down {name:w}')
|
||||
@step('I shut down {name:name}')
|
||||
def stop_patroni(context, name):
|
||||
return context.pctl.stop(name, timeout=60)
|
||||
|
||||
|
||||
@step('I kill {name:w}')
|
||||
@step('I kill {name:name}')
|
||||
def kill_patroni(context, name):
|
||||
return context.pctl.stop(name, kill=True)
|
||||
|
||||
|
||||
@step('I shut down postmaster on {name:w}')
|
||||
@step('I shut down postmaster on {name:name}')
|
||||
def stop_postgres(context, name):
|
||||
return context.pctl.stop(name, postgres=True)
|
||||
|
||||
|
||||
@step('I kill postmaster on {name:w}')
|
||||
@step('I kill postmaster on {name:name}')
|
||||
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 get_wal_name(context, pg_name):
|
||||
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
|
||||
return 'xlog' if int(version) / 10000 < 10 else 'wal'
|
||||
|
||||
|
||||
@step('I add the table {table_name:w} to {pg_name:name}')
|
||||
def add_table(context, table_name, pg_name):
|
||||
# parse the configuration file and get the port
|
||||
try:
|
||||
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name))
|
||||
context.pctl.query(pg_name, "SELECT pg_switch_{0}()".format(get_wal_name(context, pg_name)))
|
||||
except pg.Error as e:
|
||||
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
||||
|
||||
|
||||
@step('I {action:w} wal replay on {pg_name:w}')
|
||||
@step('I {action:w} wal replay on {pg_name:name}')
|
||||
def toggle_wal_replay(context, action, pg_name):
|
||||
# pause or resume the wal replay process
|
||||
try:
|
||||
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
|
||||
wal_name = 'xlog' if int(version) / 10000 < 10 else 'wal'
|
||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
|
||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(get_wal_name(context, pg_name), action))
|
||||
except pg.Error as e:
|
||||
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
|
||||
|
||||
|
||||
@step('I {action:w} table on {pg_name:w}')
|
||||
@step('I {action:w} table on {pg_name:name}')
|
||||
def crdr_mytest(context, action, pg_name):
|
||||
try:
|
||||
if (action == "create"):
|
||||
@@ -77,7 +93,7 @@ def crdr_mytest(context, action, pg_name):
|
||||
assert False, "Error {0} table mytest on {1}: {2}".format(action, pg_name, e)
|
||||
|
||||
|
||||
@step('I load data on {pg_name:w}')
|
||||
@step('I load data on {pg_name:name}')
|
||||
def initiate_load(context, pg_name):
|
||||
# perform dummy load
|
||||
try:
|
||||
@@ -86,7 +102,7 @@ def initiate_load(context, pg_name):
|
||||
assert False, "Error loading test data on {0}: {1}".format(pg_name, e)
|
||||
|
||||
|
||||
@then('Table {table_name:w} is present on {pg_name:w} after {max_replication_delay:d} seconds')
|
||||
@then('Table {table_name:w} is present on {pg_name:name} after {max_replication_delay:d} seconds')
|
||||
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
max_replication_delay *= context.timeout_multiplier
|
||||
for _ in range(int(max_replication_delay)):
|
||||
@@ -98,15 +114,15 @@ def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
|
||||
|
||||
|
||||
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
|
||||
@then('{pg_name:name} 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)), \
|
||||
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
|
||||
|
||||
|
||||
@step('replication works from {primary:w} to {replica:w} after {time_limit:d} seconds')
|
||||
@then('replication works from {primary:w} to {replica:w} after {time_limit:d} seconds')
|
||||
@step('replication works from {primary:name} to {replica:name} after {time_limit:d} seconds')
|
||||
@then('replication works from {primary:name} to {replica:name} after {time_limit:d} seconds')
|
||||
def replication_works(context, primary, replica, time_limit):
|
||||
context.execute_steps(u"""
|
||||
When I add the table test_{0} to {1}
|
||||
@@ -114,15 +130,15 @@ def replication_works(context, primary, replica, time_limit):
|
||||
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
|
||||
|
||||
|
||||
@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
|
||||
@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):
|
||||
messages_of_level = context.pctl.read_patroni_log(node, level)
|
||||
if any(any(message in line for line in messages_of_level) for message in message_list):
|
||||
break
|
||||
time.sleep(1)
|
||||
sleep(1)
|
||||
else:
|
||||
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@step('I start {name:name} in a cluster {cluster_name:w} as a long-running clone of {name2:name}')
|
||||
def start_cluster_clone(context, name, cluster_name, name2):
|
||||
context.pctl.clone(name2, cluster_name, name, True)
|
||||
|
||||
|
||||
@step('I start {name:name} in cluster {cluster_name:w} using long-running backup_restore')
|
||||
def start_patroni(context, name, cluster_name):
|
||||
return context.pctl.start(name, custom_config={
|
||||
"scope": cluster_name,
|
||||
"postgresql": {
|
||||
'create_replica_methods': ['backup_restore'],
|
||||
"backup_restore": context.pctl.backup_restore_config(long_running=True),
|
||||
'authentication': {
|
||||
'superuser': {'password': 'patroni1'},
|
||||
'replication': {'password': 'rep-pass1'}
|
||||
}
|
||||
}
|
||||
}, max_wait_limit=-1)
|
||||
|
||||
|
||||
@then('{name:name} is labeled with "{label:w}"')
|
||||
def pod_labeled(context, name, label):
|
||||
assert label in context.dcs_ctl.pod_labels(name), f'pod {name} is not labeled with {label}'
|
||||
|
||||
|
||||
@then('{name:name} is not labeled with "{label:w}"')
|
||||
def pod_not_labeled(context, name, label):
|
||||
assert label not in context.dcs_ctl.pod_labels(name), f'pod {name} is still labeled with {label}'
|
||||
@@ -4,18 +4,18 @@ import time
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@step('I configure and start {name:w} with a tag {tag_name:w} {tag_value:w}')
|
||||
@step('I configure and start {name:name} with a tag {tag_name:w} {tag_value}')
|
||||
def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
|
||||
return context.pctl.start(name, custom_config={'tags': {tag_name: tag_value}})
|
||||
|
||||
|
||||
@then('There is a {label} with "{content}" in {name:w} data directory')
|
||||
@then('There is a {label} with "{content}" in {name:name} data directory')
|
||||
def check_label(context, label, content, name):
|
||||
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')
|
||||
@step('I create label with "{content}" in {name:name} data directory')
|
||||
def write_label(context, content, name):
|
||||
context.pctl.write_label(name, content)
|
||||
|
||||
|
||||
+14
-13
@@ -1,17 +1,18 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
from dateutil import tz
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from threading import Thread, Event
|
||||
from threading import Event, Thread
|
||||
|
||||
from behave import step, then
|
||||
from dateutil import tz
|
||||
|
||||
tzutc = tz.tzutc()
|
||||
|
||||
|
||||
@step('{name:w} is a leader in a group {group:d} after {time_limit:d} seconds')
|
||||
@then('{name:w} is a leader in a group {group:d} after {time_limit:d} seconds')
|
||||
@step('{name:name} is a leader in a group {group:d} after {time_limit:d} seconds')
|
||||
@then('{name:name} is a leader in a group {group:d} after {time_limit:d} seconds')
|
||||
def is_a_group_leader(context, name, group, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
@@ -39,12 +40,12 @@ def check_group_member(context, name, group, key, value, time_limit):
|
||||
" after {5} seconds").format(name, group, key, value, response, time_limit)
|
||||
|
||||
|
||||
@step('I start {name:w} in citus group {group:d}')
|
||||
@step('I start {name:name} in citus group {group:d}')
|
||||
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 {name2:w} as the {role:w} in group {group:d} after {time_limit:d} seconds')
|
||||
@step('{name1:name} is registered in the {name2:name} 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)
|
||||
@@ -64,13 +65,13 @@ def check_registration(context, name1, name2, role, group, time_limit):
|
||||
assert False, "Node {0} is not registered in pg_dist_node on the node {1}".format(name1, name2)
|
||||
|
||||
|
||||
@step('I create a distributed table on {name:w}')
|
||||
@step('I create a distributed table on {name:name}')
|
||||
def create_distributed_table(context, name):
|
||||
context.pctl.query(name, 'CREATE TABLE public.d(id int not null)')
|
||||
context.pctl.query(name, "SELECT create_distributed_table('public.d', 'id')")
|
||||
|
||||
|
||||
@step('I cleanup a distributed table on {name:w}')
|
||||
@step('I cleanup a distributed table on {name:name}')
|
||||
def cleanup_distributed_table(context, name):
|
||||
context.pctl.query(name, 'TRUNCATE public.d')
|
||||
|
||||
@@ -86,7 +87,7 @@ def insert_thread(query_func, context):
|
||||
context.thread_stop_event.wait(0.01)
|
||||
|
||||
|
||||
@step('I start a thread inserting data on {name:w}')
|
||||
@step('I start a thread inserting data on {name:name}')
|
||||
def start_insert_thread(context, name):
|
||||
context.thread_stop_event = Event()
|
||||
context.insert_counter = 0
|
||||
@@ -109,13 +110,13 @@ def stop_insert_thread(context):
|
||||
assert not context.thread.is_alive(), "Thread is still alive"
|
||||
|
||||
|
||||
@step("a distributed table on {name:w} has expected rows")
|
||||
@step("a distributed table on {name:name} has expected rows")
|
||||
def count_rows(context, name):
|
||||
rows = context.pctl.query(name, "SELECT COUNT(*) FROM public.d").fetchone()[0]
|
||||
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 after {time_limit:d} seconds")
|
||||
@step("there is a transaction in progress on {name:name} 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)
|
||||
@@ -131,5 +132,5 @@ def check_transaction(context, name, time_limit):
|
||||
|
||||
@step("a transaction finishes in {timeout:d} seconds")
|
||||
def check_transaction_timeout(context, timeout):
|
||||
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \
|
||||
assert (datetime.now(tzutc) - context.xact_start).seconds >= timeout, \
|
||||
"a transaction finished earlier than in {0} seconds".format(timeout)
|
||||
|
||||
@@ -3,17 +3,17 @@ import time
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@step('I start {name:w} in a cluster {cluster_name:w} as a clone of {name2:w}')
|
||||
@step('I start {name:name} in a cluster {cluster_name:w} as a clone of {name2:name}')
|
||||
def start_cluster_clone(context, name, cluster_name, name2):
|
||||
context.pctl.clone(name2, cluster_name, name)
|
||||
|
||||
|
||||
@step('I start {name:w} in a cluster {cluster_name:w} from backup')
|
||||
@step('I start {name:name} in a cluster {cluster_name:w} from backup')
|
||||
def start_cluster_from_backup(context, name, cluster_name):
|
||||
context.pctl.bootstrap_from_backup(name, cluster_name)
|
||||
|
||||
|
||||
@then('{name:w} is a leader of {cluster_name:w} after {time_limit:d} seconds')
|
||||
@then('{name:name} is a leader of {cluster_name:w} after {time_limit:d} seconds')
|
||||
def is_a_leader(context, name, cluster_name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
@@ -22,6 +22,6 @@ def is_a_leader(context, name, cluster_name, time_limit):
|
||||
assert time.time() < max_time, "{0} is not a leader in dcs after {1} seconds".format(name, time_limit)
|
||||
|
||||
|
||||
@step('I do a backup of {name:w}')
|
||||
@step('I do a backup of {name:name}')
|
||||
def do_backup(context, name):
|
||||
context.pctl.backup(name)
|
||||
|
||||
@@ -11,6 +11,6 @@ def stop_dcs_outage(context):
|
||||
context.dcs_ctl.stop_outage()
|
||||
|
||||
|
||||
@step('I start {name:w} in a cluster {cluster_name:w} from backup with no_leader')
|
||||
@step('I start {name:name} in a cluster {cluster_name:w} from backup with no_leader')
|
||||
def start_cluster_from_backup_no_leader(context, name, cluster_name):
|
||||
context.pctl.bootstrap_from_backup_no_leader(name, cluster_name)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import json
|
||||
import parse
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import parse
|
||||
import yaml
|
||||
|
||||
from behave import register_type, step, then
|
||||
from dateutil import tz
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tzutc = tz.tzutc()
|
||||
|
||||
@@ -26,8 +28,8 @@ register_type(url=parse_url)
|
||||
# just rely on the database availability, since there is
|
||||
# a short gap between the time PostgreSQL becomes available
|
||||
# and Patroni assuming the leader role.
|
||||
@step('{name:w} is a leader after {time_limit:d} seconds')
|
||||
@then('{name:w} is a leader after {time_limit:d} seconds')
|
||||
@step('{name:name} is a leader after {time_limit:d} seconds')
|
||||
@then('{name:name} is a leader after {time_limit:d} seconds')
|
||||
def is_a_leader(context, name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
@@ -95,7 +97,7 @@ def do_run(context, cmd):
|
||||
context.response = response.decode('utf-8').strip()
|
||||
|
||||
|
||||
@then('I receive a response {component:w} {data}')
|
||||
@then('I receive a response {component:name} {data}')
|
||||
def check_response(context, component, data):
|
||||
if component == 'code':
|
||||
assert context.status_code == int(data), \
|
||||
@@ -114,10 +116,10 @@ def check_response(context, component, data):
|
||||
assert str(context.response[component]) == str(data), "{0} does not contain {1}".format(component, data)
|
||||
|
||||
|
||||
@step('I issue a scheduled switchover from {from_host:w} to {to_host:w} in {in_seconds:d} seconds')
|
||||
@step('I issue a scheduled switchover from {from_host:name} to {to_host:name} in {in_seconds:d} seconds')
|
||||
def scheduled_switchover(context, from_host, to_host, in_seconds):
|
||||
context.execute_steps(u"""
|
||||
Given I run patronictl.py switchover batman --master {0} --candidate {1} --scheduled "{2}" --force
|
||||
Given I run patronictl.py switchover batman --primary {0} --candidate {1} --scheduled "{2}" --force
|
||||
""".format(from_host, to_host, datetime.now(tzutc) + timedelta(seconds=int(in_seconds))))
|
||||
|
||||
|
||||
@@ -128,7 +130,13 @@ 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 add tag {tag:w} {value:w} to {pg_name:w} config')
|
||||
@step('I {action:w} {tag:w} tag in {pg_name:name} 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:name} config')
|
||||
def add_tag_to_config(context, tag, value, pg_name):
|
||||
context.pctl.add_tag_to_config(pg_name, tag, value)
|
||||
|
||||
@@ -152,9 +160,24 @@ def check_http_response(context, url, value, timeout, negate=False):
|
||||
if context.certfile:
|
||||
url = url.replace('http://', 'https://')
|
||||
timeout *= context.timeout_multiplier
|
||||
if '=' in value:
|
||||
key, val = value.split('=', 1)
|
||||
else:
|
||||
key, val = value, None
|
||||
for _ in range(int(timeout)):
|
||||
r = context.request_executor.request('GET', url)
|
||||
if (value in r.data.decode('utf-8')) != negate:
|
||||
data = r.data.decode('utf-8')
|
||||
if val is not None:
|
||||
try:
|
||||
data = json.loads(data)
|
||||
if negate:
|
||||
if key not in data or data[key] != val:
|
||||
break
|
||||
elif key in data and data[key] == val:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
elif (value in r.data.decode('utf-8')) != negate:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@step('sync key in DCS has {key:w}={value} after {time_limit:d} seconds')
|
||||
def check_sync(context, key, value, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
dcs_value = None
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
response = json.loads(context.dcs_ctl.query('sync'))
|
||||
dcs_value = response.get(key)
|
||||
if key == 'sync_standby' and set((dcs_value or '').split(',')) == set(value.split(',')):
|
||||
return
|
||||
elif str(dcs_value) == value:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "sync does not have {0}={1} (found {2}) in dcs after {3} seconds".format(key, value,
|
||||
dcs_value, time_limit)
|
||||
|
||||
|
||||
def _parse_synchronous_standby_names(value):
|
||||
if '(' in value:
|
||||
m = re.match(r'.*(\d+) \(([^)]+)\)', value)
|
||||
expected_value = set(m.group(2).split())
|
||||
expected_num = m.group(1)
|
||||
else:
|
||||
expected_value = set([value])
|
||||
expected_num = '1'
|
||||
return expected_num, expected_value
|
||||
|
||||
|
||||
@then("synchronous_standby_names on {name:2} is set to '{value}' after {time_limit:d} seconds")
|
||||
def check_synchronous_standby_names(context, name, value, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
|
||||
if value == '_empty_str_':
|
||||
value = ''
|
||||
|
||||
expected_num, expected_value = _parse_synchronous_standby_names(value)
|
||||
|
||||
ssn = None
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
ssn = context.pctl.query(name, "SHOW synchronous_standby_names").fetchone()[0]
|
||||
db_num, db_value = _parse_synchronous_standby_names(ssn)
|
||||
if expected_value == db_value and expected_num == db_num:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "synchronous_standby_names is not set to '{0}' (found '{1}') after {2} seconds".format(value, ssn,
|
||||
time_limit)
|
||||
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
|
||||
from behave import step
|
||||
|
||||
|
||||
@step('I ensure {name:name} fails to start after a failure')
|
||||
def spoil_autoconf(context, name):
|
||||
with open(os.path.join(context.pctl._processes[name]._data_dir, 'postgresql.auto.conf'), 'w') as f:
|
||||
f.write('foo=bar')
|
||||
+37
-17
@@ -2,23 +2,22 @@ import json
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
|
||||
import patroni.psycopg as pg
|
||||
|
||||
|
||||
@step('I create a logical replication slot {slot_name} on {pg_name:w} with the {plugin:w} plugin')
|
||||
def create_logical_replication_slot(context, slot_name, pg_name, plugin):
|
||||
@step('I create a logical {slot_type} slot {slot_name} on {pg_name:name} with the {plugin:w} plugin')
|
||||
def create_logical_replication_slot(context, slot_type, slot_name, pg_name, plugin):
|
||||
failover = ', failover=>true' if slot_type == 'failover' else ''
|
||||
try:
|
||||
output = context.pctl.query(pg_name, ("SELECT pg_create_logical_replication_slot('{0}', '{1}'),"
|
||||
" current_database()").format(slot_name, plugin))
|
||||
print(output.fetchone())
|
||||
context.pctl.query(pg_name, f"SELECT pg_create_logical_replication_slot('{slot_name}', '{plugin}'{failover})")
|
||||
except pg.Error as e:
|
||||
print(e)
|
||||
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
assert False, "Error creating slot {0} on {1} with plugin {2}: {3}".format(slot_name, pg_name, plugin, e)
|
||||
|
||||
|
||||
@step('{pg_name:w} has a logical replication slot named {slot_name}'
|
||||
@step('{pg_name:name} 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}'
|
||||
@then('{pg_name:name} 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
|
||||
@@ -37,8 +36,8 @@ def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit
|
||||
assert False, f"Error looking for slot {slot_name} on {pg_name} with plugin {plugin}"
|
||||
|
||||
|
||||
@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}')
|
||||
@step('{pg_name:name} does not have a replication slot named {slot_name:w}')
|
||||
@then('{pg_name:name} 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"
|
||||
@@ -48,7 +47,8 @@ def does_not_have_replication_slot(context, pg_name, slot_name):
|
||||
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
|
||||
|
||||
|
||||
@step('{slot_type:w} slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
@step('{slot_type:w} slot {slot_name:w} is in sync between '
|
||||
'{pg_name1:name} and {pg_name2:name} 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)
|
||||
@@ -67,17 +67,17 @@ def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit)
|
||||
f"{slot_type} slot {slot_name} is not in sync between {pg_name1} and {pg_name2} after {time_limit} seconds"
|
||||
|
||||
|
||||
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
|
||||
@step('I get all changes from logical slot {slot_name:w} on {pg_name:name}')
|
||||
def logical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
|
||||
|
||||
|
||||
@step('I get all changes from physical slot {slot_name:w} on {pg_name:w}')
|
||||
@step('I get all changes from physical slot {slot_name:w} on {pg_name:name}')
|
||||
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')
|
||||
@step('{pg_name:name} 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)
|
||||
@@ -93,13 +93,33 @@ def has_physical_replication_slot(context, pg_name, slot_name, time_limit):
|
||||
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}')
|
||||
@step('physical replication slot named {slot_name} on {pg_name:name} has no xmin value after {time_limit:d} seconds')
|
||||
def physical_slot_no_xmin(context, pg_name, slot_name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
query = "SELECT xmin FROM pg_catalog.pg_replication_slots WHERE slot_type = 'physical'" +\
|
||||
f" AND slot_name = '{slot_name}'"
|
||||
exists = False
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
row = context.pctl.query(pg_name, query).fetchone()
|
||||
exists = bool(row)
|
||||
if exists and row[0] is None:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, f"Physical slot {slot_name} doesn't exist after {time_limit} seconds" if not exists \
|
||||
else f"Physical slot {slot_name} has xmin value after {time_limit} seconds"
|
||||
|
||||
|
||||
@step('"{name}" key in DCS has {subkey} 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}')
|
||||
@step('"{name}" key in DCS does not have {subkey} 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}"
|
||||
|
||||
@@ -9,7 +9,7 @@ def callbacks(context, name):
|
||||
for c in ('on_start', 'on_stop', 'on_restart', 'on_role_change')}
|
||||
|
||||
|
||||
@step('I start {name:w} in a cluster {cluster_name:w}')
|
||||
@step('I start {name:name} in a cluster {cluster_name:w}')
|
||||
def start_patroni(context, name, cluster_name):
|
||||
return context.pctl.start(name, custom_config={
|
||||
"scope": cluster_name,
|
||||
@@ -20,7 +20,7 @@ def start_patroni(context, name, cluster_name):
|
||||
})
|
||||
|
||||
|
||||
@step('I start {name:w} in a standby cluster {cluster_name:w} as a clone of {name2:w}')
|
||||
@step('I start {name:name} in a standby cluster {cluster_name:w} as a clone of {name2:name}')
|
||||
def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
# we need to remove patroni.dynamic.json in order to "bootstrap" standby cluster with existing PGDATA
|
||||
os.unlink(os.path.join(context.pctl._processes[name]._data_dir, 'patroni.dynamic.json'))
|
||||
@@ -49,7 +49,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
return context.pctl.start(name)
|
||||
|
||||
|
||||
@step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds')
|
||||
@step('{pg_name1:name} is replicating from {pg_name2:name} after {timeout:d} seconds')
|
||||
def check_replication_status(context, pg_name1, pg_name2, timeout):
|
||||
bound_time = time.time() + timeout * context.timeout_multiplier
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from behave import step, then
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
|
||||
|
||||
def polling_loop(timeout, interval=1):
|
||||
"""Returns an iterator that returns values until timeout has passed. Timeout is measured from start of iteration."""
|
||||
@@ -13,12 +14,12 @@ def polling_loop(timeout, interval=1):
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
@step('I start {name:w} with watchdog')
|
||||
@step('I start {name:name} with watchdog')
|
||||
def start_patroni_with_watchdog(context, name):
|
||||
return context.pctl.start(name, custom_config={'watchdog': True, 'bootstrap': {'dcs': {'ttl': 20}}})
|
||||
|
||||
|
||||
@step('{name:w} watchdog has been pinged after {timeout:d} seconds')
|
||||
@step('{name:name} watchdog has been pinged after {timeout:d} seconds')
|
||||
def watchdog_was_pinged(context, name, timeout):
|
||||
for _ in polling_loop(timeout):
|
||||
if context.pctl.get_watchdog(name).was_pinged:
|
||||
@@ -26,22 +27,22 @@ def watchdog_was_pinged(context, name, timeout):
|
||||
return False
|
||||
|
||||
|
||||
@then('{name:w} watchdog has been closed')
|
||||
@then('{name:name} watchdog has been closed')
|
||||
def watchdog_was_closed(context, name):
|
||||
assert context.pctl.get_watchdog(name).was_closed
|
||||
|
||||
|
||||
@step('{name:w} watchdog has a {timeout:d} second timeout')
|
||||
@step('{name:name} watchdog has a {timeout:d} second timeout')
|
||||
def watchdog_has_timeout(context, name, timeout):
|
||||
assert context.pctl.get_watchdog(name).timeout == timeout
|
||||
|
||||
|
||||
@step('I reset {name:w} watchdog state')
|
||||
@step('I reset {name:name} watchdog state')
|
||||
def watchdog_reset_pinged(context, name):
|
||||
context.pctl.get_watchdog(name).reset()
|
||||
|
||||
|
||||
@then('{name:w} watchdog is triggered after {timeout:d} seconds')
|
||||
@then('{name:name} watchdog is triggered after {timeout:d} seconds')
|
||||
def watchdog_was_triggered(context, name, timeout):
|
||||
for _ in polling_loop(timeout):
|
||||
if context.pctl.get_watchdog(name).was_triggered:
|
||||
@@ -49,6 +50,6 @@ def watchdog_was_triggered(context, name, timeout):
|
||||
assert False
|
||||
|
||||
|
||||
@step('{name:w} hangs for {timeout:d} seconds')
|
||||
@step('{name:name} hangs for {timeout:d} seconds')
|
||||
def patroni_hang(context, name, timeout):
|
||||
return context.pctl.patroni_hang(name, timeout)
|
||||
|
||||
+16
-16
@@ -2,38 +2,38 @@ Feature: watchdog
|
||||
Verify that watchdog gets pinged and triggered under appropriate circumstances.
|
||||
|
||||
Scenario: watchdog is opened and pinged
|
||||
Given I start postgres0 with watchdog
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
And postgres0 watchdog has been pinged after 10 seconds
|
||||
And postgres0 watchdog has a 15 second timeout
|
||||
Given I start postgres-0 with watchdog
|
||||
Then postgres-0 is a leader after 10 seconds
|
||||
And postgres-0 role is the primary after 10 seconds
|
||||
And postgres-0 watchdog has been pinged after 10 seconds
|
||||
And postgres-0 watchdog has a 15 second timeout
|
||||
|
||||
Scenario: watchdog is reconfigured after global ttl changed
|
||||
Given I run patronictl.py edit-config batman -s ttl=30 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "+ttl: 30"
|
||||
When I sleep for 4 seconds
|
||||
Then postgres0 watchdog has a 25 second timeout
|
||||
Then postgres-0 watchdog has a 25 second timeout
|
||||
|
||||
Scenario: watchdog is disabled during pause
|
||||
Given I run patronictl.py pause batman
|
||||
Then I receive a response returncode 0
|
||||
When I sleep for 2 seconds
|
||||
Then postgres0 watchdog has been closed
|
||||
Then postgres-0 watchdog has been closed
|
||||
|
||||
Scenario: watchdog is opened and pinged after resume
|
||||
Given I reset postgres0 watchdog state
|
||||
Given I reset postgres-0 watchdog state
|
||||
And I run patronictl.py resume batman
|
||||
Then I receive a response returncode 0
|
||||
And postgres0 watchdog has been pinged after 10 seconds
|
||||
And postgres-0 watchdog has been pinged after 10 seconds
|
||||
|
||||
Scenario: watchdog is disabled when shutting down
|
||||
Given I shut down postgres0
|
||||
Then postgres0 watchdog has been closed
|
||||
Given I shut down postgres-0
|
||||
Then postgres-0 watchdog has been closed
|
||||
|
||||
Scenario: watchdog is triggered if patroni stops responding
|
||||
Given I reset postgres0 watchdog state
|
||||
And I start postgres0 with watchdog
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
When postgres0 hangs for 30 seconds
|
||||
Then postgres0 watchdog is triggered after 30 seconds
|
||||
Given I reset postgres-0 watchdog state
|
||||
And I start postgres-0 with watchdog
|
||||
Then postgres-0 role is the primary after 10 seconds
|
||||
When postgres-0 hangs for 30 seconds
|
||||
Then postgres-0 watchdog is triggered after 30 seconds
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM postgres:15
|
||||
FROM postgres:16
|
||||
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
||||
|
||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -10,7 +10,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
## 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 --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/patroni/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& PGHOME=/home/postgres \
|
||||
&& mkdir -p $PGHOME \
|
||||
&& chown postgres $PGHOME \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM postgres:15
|
||||
FROM postgres:16
|
||||
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
||||
|
||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -11,7 +11,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
## 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 \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
apt-get install -y postgresql-server-dev-15 \
|
||||
apt-get install -y postgresql-server-dev-16 \
|
||||
gcc make autoconf \
|
||||
libc6-dev flex libcurl4-gnutls-dev \
|
||||
libicu-dev libkrb5-dev liblz4-dev \
|
||||
@@ -24,10 +24,10 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
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; \
|
||||
&& apt-get -y install postgresql-16-citus-12.1; \
|
||||
fi \
|
||||
&& pip3 install --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/patroni/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& PGHOME=/home/postgres \
|
||||
&& mkdir -p $PGHOME \
|
||||
&& chown postgres $PGHOME \
|
||||
@@ -38,7 +38,7 @@ 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 \
|
||||
postgresql-server-dev-16 gcc make autoconf \
|
||||
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& apt-get autoremove -y \
|
||||
|
||||
+28
-24
@@ -23,7 +23,7 @@ Example session:
|
||||
|
||||
$ docker build -t patroni .
|
||||
Sending build context to Docker daemon 138.8kB
|
||||
Step 1/9 : FROM postgres:15
|
||||
Step 1/9 : FROM postgres:16
|
||||
...
|
||||
Successfully built e9bfe69c5d2b
|
||||
Successfully tagged patroni:latest
|
||||
@@ -46,7 +46,7 @@ Example session:
|
||||
|
||||
$ kubectl get pods -L role
|
||||
NAME READY STATUS RESTARTS AGE ROLE
|
||||
patronidemo-0 1/1 Running 0 34s master
|
||||
patronidemo-0 1/1 Running 0 34s primary
|
||||
patronidemo-1 1/1 Running 0 30s replica
|
||||
patronidemo-2 1/1 Running 0 26s replica
|
||||
|
||||
@@ -82,7 +82,7 @@ Example session:
|
||||
|
||||
demo@localhost:~/git/patroni/kubernetes$ docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
Sending build context to Docker daemon 138.8kB
|
||||
Step 1/11 : FROM postgres:15
|
||||
Step 1/11 : FROM postgres:16
|
||||
...
|
||||
Successfully built 8cd73e325028
|
||||
Successfully tagged patroni-citus-k8s:latest
|
||||
@@ -119,36 +119,40 @@ Example session:
|
||||
|
||||
$ kubectl get pods -l cluster-name=citusdemo -L role
|
||||
NAME READY STATUS RESTARTS AGE ROLE
|
||||
citusdemo-0-0 1/1 Running 0 105s master
|
||||
citusdemo-0-0 1/1 Running 0 105s primary
|
||||
citusdemo-0-1 1/1 Running 0 101s replica
|
||||
citusdemo-0-2 1/1 Running 0 96s replica
|
||||
citusdemo-1-0 1/1 Running 0 105s master
|
||||
citusdemo-1-0 1/1 Running 0 105s primary
|
||||
citusdemo-1-1 1/1 Running 0 101s replica
|
||||
citusdemo-2-0 1/1 Running 0 105s master
|
||||
citusdemo-2-0 1/1 Running 0 105s primary
|
||||
citusdemo-2-1 1/1 Running 0 101s replica
|
||||
|
||||
$ kubectl exec -ti citusdemo-0-0 -- bash
|
||||
postgres@citusdemo-0-0:~$ patronictl list
|
||||
+ Citus cluster: citusdemo -----------+--------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------------+-------------+--------------+---------+----+-----------+
|
||||
| 0 | citusdemo-0-0 | 10.244.0.10 | Leader | running | 1 | |
|
||||
| 0 | citusdemo-0-1 | 10.244.0.12 | Replica | running | 1 | 0 |
|
||||
| 0 | citusdemo-0-2 | 10.244.0.14 | Sync Standby | running | 1 | 0 |
|
||||
| 1 | citusdemo-1-0 | 10.244.0.8 | Leader | running | 1 | |
|
||||
| 1 | citusdemo-1-1 | 10.244.0.11 | Sync Standby | running | 1 | 0 |
|
||||
| 2 | citusdemo-2-0 | 10.244.0.9 | Leader | running | 1 | |
|
||||
| 2 | citusdemo-2-1 | 10.244.0.13 | Sync Standby | running | 1 | 0 |
|
||||
+-------+---------------+-------------+--------------+---------+----+-----------+
|
||||
+ Citus cluster: citusdemo -----------+----------------+---------+----+-----------+
|
||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||
+-------+---------------+-------------+----------------+---------+----+-----------+
|
||||
| 0 | citusdemo-0-0 | 10.244.0.10 | Leader | running | 1 | |
|
||||
| 0 | citusdemo-0-1 | 10.244.0.12 | Replica | running | 1 | 0 |
|
||||
| 0 | citusdemo-0-2 | 10.244.0.14 | Quorum Standby | running | 1 | 0 |
|
||||
| 1 | citusdemo-1-0 | 10.244.0.8 | Leader | running | 1 | |
|
||||
| 1 | citusdemo-1-1 | 10.244.0.11 | Quorum Standby | running | 1 | 0 |
|
||||
| 2 | citusdemo-2-0 | 10.244.0.9 | Leader | running | 1 | |
|
||||
| 2 | citusdemo-2-1 | 10.244.0.13 | Quorum Standby | running | 1 | 0 |
|
||||
+-------+---------------+-------------+----------------+---------+----+-----------+
|
||||
|
||||
postgres@citusdemo-0-0:~$ psql citus
|
||||
psql (15.1 (Debian 15.1-1.pgdg110+1))
|
||||
psql (16.4 (Debian 16.4-1.pgdg120+1))
|
||||
Type "help" for help.
|
||||
|
||||
citus=# table pg_dist_node;
|
||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||
--------+---------+-------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
||||
1 | 0 | 10.244.0.10 | 5432 | default | t | t | primary | default | t | f
|
||||
2 | 1 | 10.244.0.8 | 5432 | default | t | t | primary | default | t | t
|
||||
3 | 2 | 10.244.0.9 | 5432 | default | t | t | primary | default | t | t
|
||||
(3 rows)
|
||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||
--------+---------+-------------+----------+----------+-------------+----------+-----------+-------------+----------------+------------------
|
||||
1 | 0 | 10.244.0.10 | 5432 | default | t | t | primary | default | t | f
|
||||
2 | 1 | 10.244.0.8 | 5432 | default | t | t | primary | default | t | t
|
||||
3 | 2 | 10.244.0.9 | 5432 | default | t | t | primary | default | t | t
|
||||
4 | 0 | 10.244.0.14 | 5432 | default | t | t | secondary | default | t | f
|
||||
5 | 0 | 10.244.0.12 | 5432 | default | t | t | secondary | default | t | f
|
||||
6 | 1 | 10.244.0.11 | 5432 | default | t | t | secondary | default | t | t
|
||||
7 | 2 | 10.244.0.13 | 5432 | default | t | t | secondary | default | t | t
|
||||
(7 rows)
|
||||
|
||||
@@ -59,7 +59,7 @@ spec:
|
||||
serviceAccountName: citusdemo
|
||||
containers:
|
||||
- name: *cluster_name
|
||||
image: patroni-citus-k8s # docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus-k8s # docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
imagePullPolicy: IfNotPresent
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
@@ -169,7 +169,7 @@ spec:
|
||||
serviceAccountName: citusdemo
|
||||
containers:
|
||||
- name: *cluster_name
|
||||
image: patroni-citus-k8s # docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus-k8s # docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
imagePullPolicy: IfNotPresent
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
@@ -279,7 +279,7 @@ spec:
|
||||
serviceAccountName: citusdemo
|
||||
containers:
|
||||
- name: *cluster_name
|
||||
image: patroni-citus-k8s # docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
image: harbor.optimcloud.com/library/optim/patroni-citus-k8s # docker build -f Dockerfile.citus -t patroni-citus-k8s .
|
||||
imagePullPolicy: IfNotPresent
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
@@ -458,7 +458,7 @@ metadata:
|
||||
application: patroni
|
||||
cluster-name: citusdemo
|
||||
citus-type: worker
|
||||
role: master
|
||||
role: primary
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
|
||||
@@ -15,6 +15,7 @@ bootstrap:
|
||||
pg_hba:
|
||||
- host all all 0.0.0.0/0 md5
|
||||
- host replication ${PATRONI_REPLICATION_USERNAME} ${PATRONI_KUBERNETES_POD_IP}/16 md5
|
||||
- host replication ${PATRONI_REPLICATION_USERNAME} 127.0.0.1/32 md5
|
||||
initdb:
|
||||
- auth-host: md5
|
||||
- auth-local: trust
|
||||
|
||||
@@ -16,7 +16,7 @@ Note: If deploying as a template for multiple users, the following commands shou
|
||||
|
||||
```
|
||||
oc import-image postgres:10 --confirm -n openshift
|
||||
oc new-build https://github.com/zalando/patroni --context-dir=kubernetes -n openshift
|
||||
oc new-build https://github.com/patroni/patroni --context-dir=kubernetes -n openshift
|
||||
```
|
||||
|
||||
## Deploy the Image
|
||||
|
||||
@@ -36,7 +36,7 @@ objects:
|
||||
labels:
|
||||
application: ${APPLICATION_NAME}
|
||||
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||
name: ${PATRONI_MASTER_SERVICE_NAME}
|
||||
name: ${PATRONI_PRIMARY_SERVICE_NAME}
|
||||
spec:
|
||||
ports:
|
||||
- port: 5432
|
||||
@@ -45,7 +45,7 @@ objects:
|
||||
selector:
|
||||
application: ${APPLICATION_NAME}
|
||||
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||
role: master
|
||||
role: primary
|
||||
sessionAffinity: None
|
||||
type: ClusterIP
|
||||
status:
|
||||
@@ -289,12 +289,12 @@ parameters:
|
||||
displayName: Cluster Name
|
||||
name: PATRONI_CLUSTER_NAME
|
||||
value: patroni-ephemeral
|
||||
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-master container.
|
||||
displayName: Master service name.
|
||||
name: PATRONI_MASTER_SERVICE_NAME
|
||||
value: patroni-ephemeral-master
|
||||
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-primary container.
|
||||
displayName: Primary service name.
|
||||
name: PATRONI_PRIMARY_SERVICE_NAME
|
||||
value: patroni-ephemeral-primary
|
||||
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-replica containers.
|
||||
displayName: Replica service name.
|
||||
displayName: Replica service name.
|
||||
name: PATRONI_REPLICA_SERVICE_NAME
|
||||
value: patroni-ephemeral-replica
|
||||
- description: Maximum amount of memory the container can use.
|
||||
@@ -310,7 +310,7 @@ parameters:
|
||||
name: PATRONI_SUPERUSER_USERNAME
|
||||
value: postgres
|
||||
- description: Password of the superuser account for initialization.
|
||||
displayName: Superuser Passsword
|
||||
displayName: Superuser Password
|
||||
name: PATRONI_SUPERUSER_PASSWORD
|
||||
value: postgres
|
||||
- description: Username of the replication account for initialization.
|
||||
@@ -318,10 +318,10 @@ parameters:
|
||||
name: PATRONI_REPLICATION_USERNAME
|
||||
value: postgres
|
||||
- description: Password of the replication account for initialization.
|
||||
displayName: Repication Passsword
|
||||
displayName: Repication Password
|
||||
name: PATRONI_REPLICATION_PASSWORD
|
||||
value: postgres
|
||||
- description: Service account name used for pods and rolebindings to form a cluster in the project.
|
||||
- description: Service account name used for pods and rolebindings to form a cluster in the project.
|
||||
displayName: Service Account
|
||||
name: SERVICE_ACCOUNT
|
||||
value: patroniocp
|
||||
|
||||
@@ -34,7 +34,7 @@ objects:
|
||||
labels:
|
||||
application: ${APPLICATION_NAME}
|
||||
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||
name: ${PATRONI_MASTER_SERVICE_NAME}
|
||||
name: ${PATRONI_PRIMARY_SERVICE_NAME}
|
||||
spec:
|
||||
ports:
|
||||
- port: 5432
|
||||
@@ -43,7 +43,7 @@ objects:
|
||||
selector:
|
||||
application: ${APPLICATION_NAME}
|
||||
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||
role: master
|
||||
role: primary
|
||||
sessionAffinity: None
|
||||
type: ClusterIP
|
||||
status:
|
||||
@@ -107,7 +107,7 @@ objects:
|
||||
initContainers:
|
||||
- command:
|
||||
- sh
|
||||
- -c
|
||||
- -c
|
||||
- "mkdir -p /home/postgres/pgdata/pgroot/data && chmod 0700 /home/postgres/pgdata/pgroot/data"
|
||||
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
@@ -196,7 +196,7 @@ objects:
|
||||
terminationGracePeriodSeconds: 0
|
||||
volumes:
|
||||
- name: ${APPLICATION_NAME}
|
||||
persistentVolumeClaim:
|
||||
persistentVolumeClaim:
|
||||
claimName: ${APPLICATION_NAME}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
@@ -313,12 +313,12 @@ parameters:
|
||||
displayName: Cluster Name
|
||||
name: PATRONI_CLUSTER_NAME
|
||||
value: patroni-persistent
|
||||
- description: The name of the OpenShift Service exposed for the patroni-persistent-master container.
|
||||
displayName: Master service name.
|
||||
name: PATRONI_MASTER_SERVICE_NAME
|
||||
value: patroni-persistent-master
|
||||
- description: The name of the OpenShift Service exposed for the patroni-persistent-primary container.
|
||||
displayName: Primary service name.
|
||||
name: PATRONI_PRIMARY_SERVICE_NAME
|
||||
value: patroni-persistent-primary
|
||||
- description: The name of the OpenShift Service exposed for the patroni-persistent-replica containers.
|
||||
displayName: Replica service name.
|
||||
displayName: Replica service name.
|
||||
name: PATRONI_REPLICA_SERVICE_NAME
|
||||
value: patroni-persistent-replica
|
||||
- description: Maximum amount of memory the container can use.
|
||||
@@ -334,7 +334,7 @@ parameters:
|
||||
name: PATRONI_SUPERUSER_USERNAME
|
||||
value: postgres
|
||||
- description: Password of the superuser account for initialization.
|
||||
displayName: Superuser Passsword
|
||||
displayName: Superuser Password
|
||||
name: PATRONI_SUPERUSER_PASSWORD
|
||||
value: postgres
|
||||
- description: Username of the replication account for initialization.
|
||||
@@ -342,14 +342,14 @@ parameters:
|
||||
name: PATRONI_REPLICATION_USERNAME
|
||||
value: postgres
|
||||
- description: Password of the replication account for initialization.
|
||||
displayName: Repication Passsword
|
||||
displayName: Repication Password
|
||||
name: PATRONI_REPLICATION_PASSWORD
|
||||
value: postgres
|
||||
- description: Service account name used for pods and rolebindings to form a cluster in the project.
|
||||
- description: Service account name used for pods and rolebindings to form a cluster in the project.
|
||||
displayName: Service Account
|
||||
name: SERVICE_ACCOUNT
|
||||
value: patroni-persistent
|
||||
- description: The size of the persistent volume to create.
|
||||
- description: The size of the persistent volume to create.
|
||||
displayName: Persistent Volume Size
|
||||
name: PVC_SIZE
|
||||
value: 5Gi
|
||||
|
||||
+2
-2
@@ -15,9 +15,9 @@ pipeline {
|
||||
script {
|
||||
openshift.withCluster() {
|
||||
openshift.withProject() {
|
||||
def pgbench = openshift.newApp( "https://github.com/stewartshea/docker-pgbench/", "--name=pgbench", "-e PGPASSWORD=postgres", "-e PGUSER=postgres", "-e PGHOST=patroni-persistent-master", "-e PGDATABASE=postgres", "-e TEST_CLIENT_COUNT=20", "-e TEST_DURATION=120" )
|
||||
def pgbench = openshift.newApp( "https://github.com/stewartshea/docker-pgbench/", "--name=pgbench", "-e PGPASSWORD=postgres", "-e PGUSER=postgres", "-e PGHOST=patroni-persistent-primary", "-e PGDATABASE=postgres", "-e TEST_CLIENT_COUNT=20", "-e TEST_DURATION=120" )
|
||||
def pgbenchdc = openshift.selector( "dc", "pgbench" )
|
||||
timeout(5) {
|
||||
timeout(5) {
|
||||
pgbenchdc.rollout().status()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -13,13 +13,17 @@ def hiddenimports():
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
def resources():
|
||||
import os
|
||||
res_dir = 'patroni/postgresql/available_parameters/'
|
||||
exts = set(f.split('.')[-1] for f in os.listdir(res_dir))
|
||||
return [(res_dir + '*.' + e, res_dir) for e in exts if e.lower() in {'yml', 'yaml'}]
|
||||
|
||||
|
||||
a = Analysis(['patroni/__main__.py'],
|
||||
pathex=[],
|
||||
binaries=None,
|
||||
datas=[
|
||||
('patroni/postgresql/available_parameters/*.yml', 'patroni/postgresql/available_parameters'),
|
||||
('patroni/postgresql/available_parameters/*.yaml', 'patroni/postgresql/available_parameters'),
|
||||
],
|
||||
datas=resources(),
|
||||
hiddenimports=hiddenimports(),
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
|
||||
+80
-49
@@ -12,12 +12,13 @@ import time
|
||||
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 import global_config, MIN_PSYCOPG2, MIN_PSYCOPG3, parse_version
|
||||
from patroni.daemon import abstract_main, AbstractPatroniDaemon, get_base_arg_parser
|
||||
from patroni.tags import Tags
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .config import Config
|
||||
from .dcs import Cluster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,12 +64,16 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
||||
self.dcs = get_dcs(self.config)
|
||||
self.request = PatroniRequest(self.config, True)
|
||||
|
||||
self.ensure_unique_name()
|
||||
cluster = self.ensure_dcs_access()
|
||||
self.ensure_unique_name(cluster)
|
||||
|
||||
self.watchdog = Watchdog(self.config)
|
||||
self.load_dynamic_configuration()
|
||||
self.apply_dynamic_configuration(cluster)
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'])
|
||||
# Initialize global config
|
||||
global_config.update(None, self.config.dynamic_configuration)
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'], self.dcs.mpp)
|
||||
self.api = RestApiServer(self, self.config['restapi'])
|
||||
self.ha = Ha(self)
|
||||
|
||||
@@ -76,56 +81,63 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
||||
self.next_run = time.time()
|
||||
self.scheduled_restart: Dict[str, Any] = {}
|
||||
|
||||
def load_dynamic_configuration(self) -> None:
|
||||
"""Load Patroni dynamic configuration.
|
||||
def ensure_dcs_access(self, sleep_time: int = 5) -> 'Cluster':
|
||||
"""Continuously attempt to retrieve cluster from DCS with delay.
|
||||
|
||||
Load dynamic configuration from the DCS, if `/config` key is available in the DCS, otherwise fall back to
|
||||
:param sleep_time: seconds to wait between retry attempts after dcs connection raise :exc:`DCSError`.
|
||||
|
||||
:returns: a PostgreSQL or MPP implementation of :class:`Cluster`.
|
||||
"""
|
||||
from patroni.exceptions import DCSError
|
||||
|
||||
while True:
|
||||
try:
|
||||
return self.dcs.get_cluster()
|
||||
except DCSError:
|
||||
logger.warning('Can not get cluster from dcs')
|
||||
time.sleep(sleep_time)
|
||||
|
||||
def apply_dynamic_configuration(self, cluster: 'Cluster') -> None:
|
||||
"""Apply Patroni dynamic configuration.
|
||||
|
||||
Apply 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:
|
||||
cluster = self.dcs.get_cluster()
|
||||
if cluster and cluster.config and cluster.config.data:
|
||||
if self.config.set_dynamic_configuration(cluster.config):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
|
||||
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
break
|
||||
except DCSError:
|
||||
logger.warning('Can not get cluster from dcs')
|
||||
time.sleep(5)
|
||||
|
||||
def ensure_unique_name(self) -> None:
|
||||
"""A helper method to prevent splitbrain from operator naming error."""
|
||||
from urllib.parse import urlparse
|
||||
from urllib3.connection import HTTPConnection
|
||||
:param cluster: a PostgreSQL or MPP implementation of :class:`Cluster`.
|
||||
"""
|
||||
if cluster and cluster.config and cluster.config.data:
|
||||
if self.config.set_dynamic_configuration(cluster.config):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
|
||||
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
|
||||
self.dcs.reload_config(self.config)
|
||||
self.watchdog.reload_config(self.config)
|
||||
|
||||
def ensure_unique_name(self, cluster: 'Cluster') -> None:
|
||||
"""A helper method to prevent splitbrain from operator naming error.
|
||||
|
||||
:param cluster: a PostgreSQL or MPP implementation of :class:`Cluster`.
|
||||
"""
|
||||
from patroni.dcs import Member
|
||||
|
||||
cluster = self.dcs.get_cluster()
|
||||
if not cluster:
|
||||
return
|
||||
member = cluster.get_member(self.config['name'], False)
|
||||
if not isinstance(member, Member):
|
||||
return
|
||||
try:
|
||||
parts = urlparse(member.api_url)
|
||||
if isinstance(parts.hostname, str):
|
||||
connection = HTTPConnection(parts.hostname, port=parts.port or 80, timeout=3)
|
||||
connection.connect()
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
# Silence annoying WARNING: Retrying (...) messages when Patroni is quickly restarted.
|
||||
# At this moment we don't have custom log levels configured and hence shouldn't lose anything useful.
|
||||
self.logger.update_loggers({'urllib3.connectionpool': 'ERROR'})
|
||||
_ = self.request(member, endpoint="/liveness", timeout=3)
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
return
|
||||
self.logger.update_loggers({})
|
||||
|
||||
def _get_tags(self) -> Dict[str, Any]:
|
||||
"""Get tags configured for this node, if any.
|
||||
@@ -200,13 +212,15 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
||||
the change and cache the new dynamic configuration values in ``patroni.dynamic.json`` file under Postgres data
|
||||
directory.
|
||||
"""
|
||||
from patroni.postgresql.misc import PostgresqlRole
|
||||
|
||||
logger.info(self.ha.run_cycle())
|
||||
|
||||
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
|
||||
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
|
||||
self.reload_config()
|
||||
|
||||
if self.postgresql.role != 'uninitialized':
|
||||
if self.postgresql.role != PostgresqlRole.UNINITIALIZED:
|
||||
self.config.save_cache()
|
||||
|
||||
self.schedule_next_run()
|
||||
@@ -231,11 +245,6 @@ def patroni_main(configfile: str) -> None:
|
||||
|
||||
:param configfile: path to Patroni configuration file.
|
||||
"""
|
||||
from multiprocessing import freeze_support
|
||||
|
||||
# Windows 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, configfile)
|
||||
|
||||
|
||||
@@ -248,6 +257,10 @@ def process_arguments() -> Namespace:
|
||||
* ``--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
|
||||
* ``--ignore-listen-port`` | ``-i`` -- used to ignore ``listen`` ports already in use.
|
||||
Can be used only with ``--validate-config``
|
||||
* ``--print`` | ``-p`` -- used to print out local configuration (incl. environment configuration overrides).
|
||||
Can be used only with ``--validate-config``
|
||||
|
||||
.. note::
|
||||
If running with ``--generate-config``, ``--generate-sample-config`` or ``--validate-flag`` will exit
|
||||
@@ -266,6 +279,12 @@ def process_arguments() -> Namespace:
|
||||
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.')
|
||||
parser.add_argument('--ignore-listen-port', '-i', action='store_true',
|
||||
help='Ignore `listen` ports already in use.\
|
||||
Can only be used with --validate-config')
|
||||
parser.add_argument('--print', '-p', action='store_true',
|
||||
help='Print out local configuration (incl. environment configuration overrides).\
|
||||
Can only be used with --validate-config')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.generate_sample_config:
|
||||
@@ -275,15 +294,21 @@ def process_arguments() -> Namespace:
|
||||
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
|
||||
from patroni.validator import populate_validate_params, schema
|
||||
|
||||
populate_validate_params(ignore_listen_port=args.ignore_listen_port)
|
||||
|
||||
try:
|
||||
Config(args.configfile, validator=schema)
|
||||
sys.exit()
|
||||
config = Config(args.configfile, validator=schema)
|
||||
except ConfigParseError as e:
|
||||
sys.exit(e.value)
|
||||
|
||||
if args.print:
|
||||
import yaml
|
||||
yaml.safe_dump(config.local_configuration, sys.stdout, default_flow_style=False, allow_unicode=True)
|
||||
sys.exit()
|
||||
|
||||
return args
|
||||
|
||||
|
||||
@@ -337,6 +362,12 @@ def main() -> None:
|
||||
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
|
||||
to ``patroni`` daemon process.
|
||||
"""
|
||||
from multiprocessing import freeze_support
|
||||
|
||||
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
|
||||
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
|
||||
freeze_support()
|
||||
|
||||
check_psycopg()
|
||||
|
||||
args = process_arguments()
|
||||
|
||||
+162
-101
@@ -7,43 +7,43 @@ utilises the API to perform these functions.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
import dateutil.parser
|
||||
import datetime
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from ipaddress import ip_address, ip_network, IPv4Network, IPv6Network
|
||||
from socketserver import ThreadingMixIn
|
||||
from threading import Thread
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from typing import Any, Callable, cast, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
|
||||
import dateutil.parser
|
||||
|
||||
from . import psycopg
|
||||
from . import global_config, psycopg
|
||||
from .__main__ import Patroni
|
||||
from .dcs import Cluster
|
||||
from .exceptions import PostgresConnectionException, PostgresException
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
|
||||
RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json
|
||||
from .postgresql.misc import postgres_version_to_int, PostgresqlRole, PostgresqlState
|
||||
from .utils import cluster_as_json, deep_compare, enable_keepalive, parse_bool, \
|
||||
parse_int, patch_config, Retry, RetryFailedError, split_host_port, tzutc, uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None]:
|
||||
def check_access(*args: Any, **kwargs: Any) -> Callable[..., Any]:
|
||||
"""Check the source ip, authorization header, or client certificates.
|
||||
|
||||
.. note::
|
||||
The actual logic to check access is implemented through :func:`RestApiServer.check_access`.
|
||||
|
||||
:param func: function to be decorated.
|
||||
Optionally it is possible to skip source ip check by specifying ``allowlist_check_members=False``.
|
||||
|
||||
:returns: a decorator that executes *func* only if :func:`RestApiServer.check_access` returns ``True``.
|
||||
|
||||
@@ -60,19 +60,31 @@ def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None
|
||||
... @check_access
|
||||
... def do_PUT_foo(self):
|
||||
... print('In do_PUT_foo')
|
||||
... @check_access(allowlist_check_members=False)
|
||||
... def do_POST_bar(self):
|
||||
... print('In do_POST_bar')
|
||||
|
||||
>>> f = Foo()
|
||||
>>> f.do_PUT_foo()
|
||||
In FooServer: Foo
|
||||
In do_PUT_foo
|
||||
|
||||
"""
|
||||
allowlist_check_members = kwargs.get('allowlist_check_members', True)
|
||||
|
||||
def wrapper(self: 'RestApiHandler', *args: Any, **kwargs: Any) -> None:
|
||||
if self.server.check_access(self):
|
||||
return func(self, *args, **kwargs)
|
||||
def inner_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def wrapper(self: 'RestApiHandler', *args: Any, **kwargs: Any) -> Any:
|
||||
if self.server.check_access(self, allowlist_check_members=allowlist_check_members):
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return wrapper
|
||||
|
||||
# A hacky way to have decorators that work with and without parameters.
|
||||
if len(args) == 1 and callable(args[0]):
|
||||
# The first parameter is a function, it means decorator is used as "@check_access"
|
||||
return inner_decorator(args[0])
|
||||
else:
|
||||
# @check_access(allowlist_check_members=False) case
|
||||
return inner_decorator
|
||||
|
||||
|
||||
class RestApiHandler(BaseHTTPRequestHandler):
|
||||
@@ -180,6 +192,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
|
||||
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
|
||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
||||
* ``pending_restart_reason``: dictionary where each key is the parameter that caused "pending restart" flag
|
||||
to be set and the value is a dictionary with the old and the new value.
|
||||
* ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the
|
||||
scheduled restart;
|
||||
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
|
||||
@@ -196,8 +210,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
response['tags'] = tags
|
||||
if patroni.postgresql.sysid:
|
||||
response['database_system_identifier'] = patroni.postgresql.sysid
|
||||
if patroni.postgresql.pending_restart:
|
||||
if patroni.postgresql.pending_restart_reason:
|
||||
response['pending_restart'] = True
|
||||
response['pending_restart_reason'] = dict(patroni.postgresql.pending_restart_reason)
|
||||
response['patroni'] = {
|
||||
'version': patroni.version,
|
||||
'scope': patroni.postgresql.scope,
|
||||
@@ -251,6 +266,14 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
* HTTP status ``200``: if up and running and without ``noloadbalance`` tag.
|
||||
|
||||
* ``/quorum``:
|
||||
|
||||
* HTTP status ``200``: if up and running as a quorum synchronous standby.
|
||||
|
||||
* ``/read-only-quorum``:
|
||||
|
||||
* HTTP status ``200``: if up and running as a quorum synchronous standby or primary.
|
||||
|
||||
* ``/synchronous`` or ``/sync``:
|
||||
|
||||
* HTTP status ``200``: if up and running as a synchronous standby.
|
||||
@@ -287,12 +310,13 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""
|
||||
path = '/primary' if self.path == '/' else self.path
|
||||
response = self.get_postgresql_status()
|
||||
latest_end_lsn = response.pop('latest_end_lsn', 0)
|
||||
|
||||
patroni = self.server.patroni
|
||||
cluster = patroni.dcs.cluster
|
||||
global_config = patroni.config.get_global_config(cluster)
|
||||
config = global_config.from_cluster(cluster)
|
||||
|
||||
leader_optime = cluster and cluster.last_lsn or 0
|
||||
leader_optime = max(cluster and cluster.status.last_lsn or 0, latest_end_lsn)
|
||||
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
|
||||
max_replica_lag = parse_int(self.path_query.get('lag', [sys.maxsize])[0], 'B')
|
||||
if max_replica_lag is None:
|
||||
@@ -300,17 +324,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
is_lagging = leader_optime and leader_optime > replayed_location + max_replica_lag
|
||||
|
||||
replica_status_code = 200 if not patroni.noloadbalance and not is_lagging and \
|
||||
response.get('role') == 'replica' and response.get('state') == 'running' else 503
|
||||
response.get('role') == PostgresqlRole.REPLICA and response.get('state') == PostgresqlState.RUNNING else 503
|
||||
|
||||
if not cluster and response.get('pause'):
|
||||
leader_status_code = 200 if response.get('role') in ('master', 'primary', 'standby_leader') else 503
|
||||
primary_status_code = 200 if response.get('role') in ('master', 'primary') else 503
|
||||
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
|
||||
leader_status_code = 200 if response.get('role') in (PostgresqlRole.PRIMARY,
|
||||
PostgresqlRole.STANDBY_LEADER) else 503
|
||||
primary_status_code = 200 if response.get('role') == PostgresqlRole.PRIMARY else 503
|
||||
standby_leader_status_code = 200 if response.get('role') == PostgresqlRole.STANDBY_LEADER else 503
|
||||
elif patroni.ha.is_leader():
|
||||
leader_status_code = 200
|
||||
if global_config.is_standby_cluster:
|
||||
if config.is_standby_cluster:
|
||||
primary_status_code = replica_status_code = 503
|
||||
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
|
||||
standby_leader_status_code =\
|
||||
200 if response.get('role') in (PostgresqlRole.REPLICA, PostgresqlRole.STANDBY_LEADER) else 503
|
||||
else:
|
||||
primary_status_code = 200
|
||||
standby_leader_status_code = 503
|
||||
@@ -331,16 +357,24 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
ignore_tags = True
|
||||
elif 'replica' in path:
|
||||
status_code = replica_status_code
|
||||
elif 'read-only' in path and 'sync' not in path:
|
||||
elif 'read-only' in path and 'sync' not in path and 'quorum' not in path:
|
||||
status_code = 200 if 200 in (primary_status_code, standby_leader_status_code) else replica_status_code
|
||||
elif 'health' in path:
|
||||
status_code = 200 if response.get('state') == 'running' else 503
|
||||
status_code = 200 if response.get('state') == PostgresqlState.RUNNING else 503
|
||||
elif cluster: # dcs is available
|
||||
is_quorum = response.get('quorum_standby')
|
||||
is_synchronous = response.get('sync_standby')
|
||||
if path in ('/sync', '/synchronous') and is_synchronous:
|
||||
status_code = replica_status_code
|
||||
elif path in ('/async', '/asynchronous') and not is_synchronous:
|
||||
elif path == '/quorum' and is_quorum:
|
||||
status_code = replica_status_code
|
||||
elif path in ('/async', '/asynchronous') and not is_synchronous and not is_quorum:
|
||||
status_code = replica_status_code
|
||||
elif path == '/read-only-quorum':
|
||||
if 200 in (primary_status_code, standby_leader_status_code):
|
||||
status_code = 200
|
||||
elif is_quorum:
|
||||
status_code = replica_status_code
|
||||
elif path in ('/read-only-sync', '/read-only-synchronous'):
|
||||
if 200 in (primary_status_code, standby_leader_status_code):
|
||||
status_code = 200
|
||||
@@ -404,7 +438,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
"""
|
||||
patroni: Patroni = self.server.patroni
|
||||
is_primary = patroni.postgresql.role in ('master', 'primary') and patroni.postgresql.is_running()
|
||||
is_primary = patroni.postgresql.role == PostgresqlRole.PRIMARY and patroni.postgresql.is_running()
|
||||
# We can tolerate Patroni problems longer on the replica.
|
||||
# On the primary the liveness probe most likely will start failing only after the leader key expired.
|
||||
# It should not be a big problem because replicas will see that the primary is still alive via REST API call.
|
||||
@@ -430,7 +464,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
patroni = self.server.patroni
|
||||
if patroni.ha.is_leader():
|
||||
status_code = 200
|
||||
elif patroni.postgresql.state == 'running':
|
||||
elif patroni.postgresql.state == PostgresqlState.RUNNING:
|
||||
status_code = 200 if patroni.dcs.cluster else 503
|
||||
else:
|
||||
status_code = 503
|
||||
@@ -443,6 +477,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Postgres.
|
||||
"""
|
||||
response = self.get_postgresql_status(True)
|
||||
response.pop('latest_end_lsn', None)
|
||||
self._write_status_response(200, response)
|
||||
|
||||
def do_GET_cluster(self) -> None:
|
||||
@@ -452,9 +487,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
HTTP status ``200`` and the JSON representation of the cluster topology.
|
||||
"""
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||
|
||||
response = cluster_as_json(cluster, global_config)
|
||||
response = cluster_as_json(cluster)
|
||||
response['scope'] = self.server.patroni.postgresql.scope
|
||||
self._write_json_response(200, response)
|
||||
|
||||
@@ -502,12 +536,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
* ``patroni_version``: Patroni version without periods, e.g. ``030002`` for Patroni ``3.0.2``;
|
||||
* ``patroni_postgres_running``: ``1`` if PostgreSQL is running, else ``0``;
|
||||
* ``patroni_postmaster_start_time``: epoch timestamp since Postmaster was started;
|
||||
* ``patroni_master``: ``1`` if this node holds the leader lock, else ``0``;
|
||||
* ``patroni_primary``: same as ``patroni_master``;
|
||||
* ``patroni_primary``: ``1`` if this node holds the leader lock, else ``0``;
|
||||
* ``patroni_xlog_location``: ``pg_wal_lsn_diff(pg_current_wal_flush_lsn(), '0/0')`` if leader, else ``0``;
|
||||
* ``patroni_standby_leader``: ``1`` if standby leader node, else ``0``;
|
||||
* ``patroni_replica``: ``1`` if a replica, else ``0``;
|
||||
* ``patroni_sync_standby``: ``1`` if a sync replica, else ``0``;
|
||||
* ``patroni_quorum_standby``: ``1`` if a quorum sync replica, else ``0``;
|
||||
* ``patroni_xlog_received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
||||
* ``patroni_xlog_replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
||||
* ``patroni_xlog_replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
|
||||
@@ -541,7 +575,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
metrics.append("# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_running gauge")
|
||||
metrics.append("patroni_postgres_running{0} {1}".format(labels, int(postgres['state'] == 'running')))
|
||||
metrics.append("patroni_postgres_running{0} {1}".format(
|
||||
labels, int(postgres['state'] == PostgresqlState.RUNNING)))
|
||||
|
||||
metrics.append("# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.")
|
||||
metrics.append("# TYPE patroni_postmaster_start_time gauge")
|
||||
@@ -549,13 +584,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
postmaster_start_time = (postmaster_start_time - epoch).total_seconds() if postmaster_start_time else 0
|
||||
metrics.append("patroni_postmaster_start_time{0} {1}".format(labels, postmaster_start_time))
|
||||
|
||||
metrics.append("# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_master gauge")
|
||||
metrics.append("patroni_master{0} {1}".format(labels, int(postgres['role'] in ('master', 'primary'))))
|
||||
|
||||
metrics.append("# HELP patroni_primary Value is 1 if this node is the leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_primary gauge")
|
||||
metrics.append("patroni_primary{0} {1}".format(labels, int(postgres['role'] in ('master', 'primary'))))
|
||||
metrics.append("patroni_primary{0} {1}".format(labels, int(postgres['role'] == PostgresqlRole.PRIMARY)))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_location Current location of the Postgres"
|
||||
" transaction log, 0 if this node is not the leader.")
|
||||
@@ -564,16 +595,21 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
metrics.append("# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_standby_leader gauge")
|
||||
metrics.append("patroni_standby_leader{0} {1}".format(labels, int(postgres['role'] == 'standby_leader')))
|
||||
metrics.append("patroni_standby_leader{0} {1}".format(labels,
|
||||
int(postgres['role'] == PostgresqlRole.STANDBY_LEADER)))
|
||||
|
||||
metrics.append("# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_replica gauge")
|
||||
metrics.append("patroni_replica{0} {1}".format(labels, int(postgres['role'] == 'replica')))
|
||||
metrics.append("patroni_replica{0} {1}".format(labels, int(postgres['role'] == PostgresqlRole.REPLICA)))
|
||||
|
||||
metrics.append("# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.")
|
||||
metrics.append("# HELP patroni_sync_standby Value is 1 if this node is a sync standby, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_sync_standby gauge")
|
||||
metrics.append("patroni_sync_standby{0} {1}".format(labels, int(postgres.get('sync_standby', False))))
|
||||
|
||||
metrics.append("# HELP patroni_quorum_standby Value is 1 if this node is a quorum standby, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_quorum_standby gauge")
|
||||
metrics.append("patroni_quorum_standby{0} {1}".format(labels, int(postgres.get('quorum_standby', False))))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
|
||||
" Postgres transaction log, 0 if this node is not a replica.")
|
||||
metrics.append("# TYPE patroni_xlog_received_location counter")
|
||||
@@ -625,7 +661,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_timeline counter")
|
||||
metrics.append("patroni_postgres_timeline{0} {1}".format(labels, postgres.get('timeline', 0)))
|
||||
metrics.append("patroni_postgres_timeline{0} {1}".format(labels, postgres.get('timeline') or 0))
|
||||
|
||||
metrics.append("# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully"
|
||||
" by Patroni.")
|
||||
@@ -635,7 +671,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_pending_restart gauge")
|
||||
metrics.append("patroni_pending_restart{0} {1}"
|
||||
.format(labels, int(patroni.postgresql.pending_restart)))
|
||||
.format(labels, int(bool(patroni.postgresql.pending_restart_reason))))
|
||||
|
||||
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_is_paused gauge")
|
||||
@@ -668,9 +704,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
content_length = int(self.headers.get('content-length') or 0)
|
||||
if content_length == 0 and body_is_optional:
|
||||
return {}
|
||||
request: Union[Dict[str, Any], Any] = json.loads(self.rfile.read(content_length).decode('utf-8'))
|
||||
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
|
||||
if isinstance(request, dict) and (request or body_is_optional):
|
||||
return request
|
||||
return cast(Dict[str, Any], request)
|
||||
except Exception:
|
||||
logger.exception('Bad request')
|
||||
self.send_error(400)
|
||||
@@ -745,12 +781,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_error(502)
|
||||
|
||||
@check_access
|
||||
@check_access(allowlist_check_members=False)
|
||||
def do_POST_failsafe(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/failsafe`` path.
|
||||
|
||||
Writes a response with HTTP status ``200`` if this node is a Standby, or with HTTP status ``500`` if this is
|
||||
the primary.
|
||||
the primary. In addition to that it returns absolute value of received/replayed LSN in the ``lsn`` header.
|
||||
|
||||
.. note::
|
||||
If ``failsafe_mode`` is not enabled, then write a response with HTTP status ``502``.
|
||||
@@ -758,9 +794,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if self.server.patroni.ha.is_failsafe_mode():
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
message = self.server.patroni.ha.update_failsafe(request) or 'Accepted'
|
||||
ret = self.server.patroni.ha.update_failsafe(request)
|
||||
headers = {'lsn': str(ret)} if isinstance(ret, int) else {}
|
||||
message = ret if isinstance(ret, str) else 'Accepted'
|
||||
code = 200 if message == 'Accepted' else 500
|
||||
self.write_response(code, message)
|
||||
self.write_response(code, message, headers=headers)
|
||||
else:
|
||||
self.send_error(502)
|
||||
|
||||
@@ -826,7 +864,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
* ``schedule``: timestamp at which the restart should occur;
|
||||
* ``role``: restart only nodes which role is ``role``. Can be either:
|
||||
|
||||
* ``primary`` (or ``master``); or
|
||||
* ``primary`; or
|
||||
* ``replica``.
|
||||
|
||||
* ``postgres_version``: restart only nodes which PostgreSQL version is less than ``postgres_version``, e.g.
|
||||
@@ -855,7 +893,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
If it's not able to parse the request body, then the request is silently discarded.
|
||||
"""
|
||||
status_code = 500
|
||||
data = 'restart failed'
|
||||
data = PostgresqlState.RESTART_FAILED
|
||||
request = self._read_json_content(body_is_optional=True)
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
if request is None:
|
||||
@@ -864,7 +902,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if request:
|
||||
logger.debug("received restart request: {0}".format(request))
|
||||
|
||||
if self.server.patroni.config.get_global_config(cluster).is_paused and 'schedule' in request:
|
||||
if global_config.from_cluster(cluster).is_paused and 'schedule' in request:
|
||||
self.write_response(status_code, "Can't schedule restart in the paused state")
|
||||
return
|
||||
|
||||
@@ -875,9 +913,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
status_code = _
|
||||
break
|
||||
elif k == 'role':
|
||||
if request[k] not in ('master', 'primary', 'replica'):
|
||||
if request[k] not in (PostgresqlRole.PRIMARY, PostgresqlRole.STANDBY_LEADER, PostgresqlRole.REPLICA):
|
||||
status_code = 400
|
||||
data = "PostgreSQL role should be either primary or replica"
|
||||
data = "PostgreSQL role should be either primary, standby_leader, or replica"
|
||||
break
|
||||
elif k == 'postgres_version':
|
||||
try:
|
||||
@@ -1033,16 +1071,17 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
:returns: a string with the error message or ``None`` if good nodes are found.
|
||||
"""
|
||||
is_synchronous_mode = self.server.patroni.config.get_global_config(cluster).is_synchronous_mode
|
||||
config = global_config.from_cluster(cluster)
|
||||
if leader and (not cluster.leader or cluster.leader.name != leader):
|
||||
return 'leader name does not match'
|
||||
if candidate:
|
||||
if action == 'switchover' and is_synchronous_mode and not cluster.sync.matches(candidate):
|
||||
if action == 'switchover' and config.is_synchronous_mode\
|
||||
and not config.is_quorum_commit_mode and not cluster.sync.matches(candidate):
|
||||
return 'candidate name does not match with sync_standby'
|
||||
members = [m for m in cluster.members if m.name == candidate]
|
||||
if not members:
|
||||
return 'candidate does not exists'
|
||||
elif is_synchronous_mode:
|
||||
elif config.is_synchronous_mode and not config.is_quorum_commit_mode:
|
||||
members = [m for m in cluster.members if cluster.sync.matches(m.name)]
|
||||
if not members:
|
||||
return action + ' is not possible: can not find sync_standby'
|
||||
@@ -1091,7 +1130,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
candidate = request.get('candidate') or request.get('member')
|
||||
scheduled_at = request.get('scheduled_at')
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||
config = global_config.from_cluster(cluster)
|
||||
|
||||
logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
|
||||
action, leader, candidate, scheduled_at)
|
||||
@@ -1104,16 +1143,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if not data and scheduled_at:
|
||||
if action == 'failover':
|
||||
data = "Failover can't be scheduled"
|
||||
elif global_config.is_paused:
|
||||
elif config.is_paused:
|
||||
data = "Can't schedule switchover in the paused state"
|
||||
else:
|
||||
(status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action)
|
||||
|
||||
if not data and global_config.is_paused and not candidate:
|
||||
if not data and config.is_paused and not candidate:
|
||||
data = 'Switchover is possible only to a specific candidate in a paused state'
|
||||
|
||||
if action == 'failover' and leader:
|
||||
logger.warning('received failover request with leader specifed - performing switchover instead')
|
||||
logger.warning('received failover request with leader specified - performing switchover instead')
|
||||
action = 'switchover'
|
||||
|
||||
if not data and leader == candidate:
|
||||
@@ -1154,8 +1193,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_POST_citus(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/citus`` path.
|
||||
|
||||
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
|
||||
HTTP status code ``200``.
|
||||
.. note::
|
||||
We keep this entrypoint for backward compatibility and simply dispatch the request to :meth:`do_POST_mpp`.
|
||||
"""
|
||||
self.do_POST_mpp()
|
||||
|
||||
def do_POST_mpp(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/mpp`` path.
|
||||
|
||||
Call :func:`~patroni.postgresql.mpp.AbstractMPPHandler.handle_event` to handle the request,
|
||||
then write a response with HTTP status code ``200``.
|
||||
|
||||
.. note::
|
||||
If unable to parse the request body, then the request is silently discarded.
|
||||
@@ -1165,9 +1212,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
patroni = self.server.patroni
|
||||
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
|
||||
if patroni.postgresql.mpp_handler.is_coordinator() and patroni.ha.is_leader():
|
||||
cluster = patroni.dcs.get_cluster()
|
||||
patroni.postgresql.citus_handler.handle_event(cluster, request)
|
||||
patroni.postgresql.mpp_handler.handle_event(cluster, request)
|
||||
self.write_response(200, 'OK')
|
||||
|
||||
def parse_request(self) -> bool:
|
||||
@@ -1220,20 +1267,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
:returns: a dict with the status of Postgres/Patroni. The keys are:
|
||||
|
||||
* ``state``: Postgres state among ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``,
|
||||
``starting``, ``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``,
|
||||
``initdb failed``, ``running custom bootstrap script``, ``custom bootstrap failed``,
|
||||
``creating replica``, or ``unknown``;
|
||||
* ``state``: one of :class:`~patroni.postgresql.misc.PostgresqlState` or ``unknown``;
|
||||
* ``postmaster_start_time``: ``pg_postmaster_start_time()``;
|
||||
* ``role``: ``replica`` or ``master`` based on ``pg_is_in_recovery()`` output;
|
||||
* ``role``: :class:`~patroni.postgresql.misc.PostgresqlRole.REPLICA` or
|
||||
:class:`~patroni.postgresql.misc.PostgresqlRole.PRIMARY` based on ``pg_is_in_recovery()`` output;
|
||||
* ``server_version``: Postgres version without periods, e.g. ``150002`` for Postgres ``15.2``;
|
||||
* ``latest_end_lsn``: latest_end_lsn value from ``pg_stat_get_wal_receiver()``, only on replica nodes;
|
||||
* ``xlog``: dictionary. Its structure depends on ``role``:
|
||||
|
||||
* If ``master``:
|
||||
* If :class:`~patroni.postgresql.misc.PostgresqlRole.PRIMARY`:
|
||||
|
||||
* ``location``: ``pg_current_wal_flush_lsn()``
|
||||
|
||||
* If ``replica``:
|
||||
* If :class:`~patroni.postgresql.misc.PostgresqlRole.REPLICA`:
|
||||
|
||||
* ``received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
||||
* ``replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
||||
@@ -1241,6 +1287,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
* ``paused``: ``pg_is_wal_replay_paused()``;
|
||||
|
||||
* ``sync_standby``: ``True`` if replication mode is synchronous and this is a sync standby;
|
||||
* ``quorum_standby``: ``True`` if replication mode is quorum and this is a quorum standby;
|
||||
* ``timeline``: PostgreSQL primary node timeline;
|
||||
* ``replication``: :class:`list` of :class:`dict` entries, one for each replication connection. Each entry
|
||||
contains the following keys:
|
||||
@@ -1260,30 +1307,34 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""
|
||||
postgresql = self.server.patroni.postgresql
|
||||
cluster = self.server.patroni.dcs.cluster
|
||||
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||
config = global_config.from_cluster(cluster)
|
||||
try:
|
||||
|
||||
if postgresql.state not in ('running', 'restarting', 'starting'):
|
||||
if postgresql.state not in (PostgresqlState.RUNNING, PostgresqlState.RESTARTING,
|
||||
PostgresqlState.STARTING):
|
||||
raise RetryFailedError('')
|
||||
replication_state = ('(pg_catalog.pg_stat_get_wal_receiver()).status'
|
||||
if postgresql.major_version >= 90600 else 'NULL') + ", " +\
|
||||
("pg_catalog.current_setting('restore_command')" if postgresql.major_version >= 120000 else "NULL")
|
||||
replication_state = ("pg_catalog.pg_{0}_{1}_diff(wr.latest_end_lsn, '0/0')::bigint, wr.status"
|
||||
if postgresql.major_version >= 90600 else "NULL, NULL") + ", " +\
|
||||
("pg_catalog.current_setting('restore_command')" if postgresql.major_version >= 120000 else "NULL") +\
|
||||
", " + ("pg_catalog.pg_wal_lsn_diff(wr.written_lsn, '0/0')::bigint"
|
||||
if postgresql.major_version >= 130000 else "NULL")
|
||||
stmt = ("SELECT " + postgresql.POSTMASTER_START_TIME + ", " + postgresql.TL_LSN + ","
|
||||
" pg_catalog.pg_last_xact_replay_timestamp(), " + replication_state + ","
|
||||
" pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
|
||||
" (SELECT pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
|
||||
"FROM (SELECT (SELECT rolname FROM pg_catalog.pg_authid WHERE oid = usesysid) AS usename,"
|
||||
" application_name, client_addr, w.state, sync_state, sync_priority"
|
||||
" FROM pg_catalog.pg_stat_get_wal_senders() w, pg_catalog.pg_stat_get_activity(pid)) AS ri")
|
||||
" FROM pg_catalog.pg_stat_get_wal_senders() w, pg_catalog.pg_stat_get_activity(pid)) AS ri)") +\
|
||||
(" FROM pg_catalog.pg_stat_get_wal_receiver() AS wr" if postgresql.major_version >= 90600 else "")
|
||||
|
||||
row = self.query(stmt.format(postgresql.wal_name, postgresql.lsn_name,
|
||||
postgresql.wal_flush), retry=retry)[0]
|
||||
result = {
|
||||
'state': postgresql.state,
|
||||
'postmaster_start_time': row[0],
|
||||
'role': 'replica' if row[1] == 0 else 'master',
|
||||
'role': PostgresqlRole.REPLICA if row[1] == 0 else PostgresqlRole.PRIMARY,
|
||||
'server_version': postgresql.server_version,
|
||||
'xlog': ({
|
||||
'received_location': row[4] or row[3],
|
||||
'received_location': row[10] or row[4] or row[3],
|
||||
'replayed_location': row[3],
|
||||
'replayed_timestamp': row[6],
|
||||
'paused': row[5]} if row[1] == 0 else {
|
||||
@@ -1291,12 +1342,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
})
|
||||
}
|
||||
|
||||
if result['role'] == 'replica' and global_config.is_standby_cluster:
|
||||
if result['role'] == PostgresqlRole.REPLICA and config.is_standby_cluster:
|
||||
result['role'] = postgresql.role
|
||||
|
||||
if result['role'] == 'replica' and global_config.is_synchronous_mode\
|
||||
if result['role'] == PostgresqlRole.REPLICA and config.is_synchronous_mode\
|
||||
and cluster and cluster.sync.matches(postgresql.name):
|
||||
result['sync_standby'] = True
|
||||
result['quorum_standby' if global_config.is_quorum_commit_mode else 'sync_standby'] = True
|
||||
|
||||
if row[1] > 0:
|
||||
result['timeline'] = row[1]
|
||||
@@ -1305,21 +1356,24 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if not cluster or cluster.is_unlocked() or not cluster.leader else cluster.leader.timeline
|
||||
result['timeline'] = postgresql.replica_cached_timeline(leader_timeline)
|
||||
|
||||
replication_state = postgresql.replication_state_from_parameters(row[1] > 0, row[7], row[8])
|
||||
if row[7]:
|
||||
result['latest_end_lsn'] = row[7]
|
||||
|
||||
replication_state = postgresql.replication_state_from_parameters(row[1] > 0, row[8], row[9])
|
||||
if replication_state:
|
||||
result['replication_state'] = replication_state
|
||||
|
||||
if row[9]:
|
||||
result['replication'] = row[9]
|
||||
if row[11]:
|
||||
result['replication'] = row[11]
|
||||
|
||||
except (psycopg.Error, RetryFailedError, PostgresConnectionException):
|
||||
state = postgresql.state
|
||||
if state == 'running':
|
||||
if state == PostgresqlState.RUNNING:
|
||||
logger.exception('get_postgresql_status')
|
||||
state = 'unknown'
|
||||
result: Dict[str, Any] = {'state': state, 'role': postgresql.role}
|
||||
|
||||
if global_config.is_paused:
|
||||
if config.is_paused:
|
||||
result['pause'] = True
|
||||
if not cluster or cluster.is_unlocked():
|
||||
result['cluster_unlocked'] = True
|
||||
@@ -1489,7 +1543,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
except Exception as e:
|
||||
logger.debug('Failed to parse url %s: %r', member.api_url, e)
|
||||
|
||||
def check_access(self, rh: RestApiHandler) -> Optional[bool]:
|
||||
def check_access(self, rh: RestApiHandler, allowlist_check_members: bool = True) -> Optional[bool]:
|
||||
"""Ensure client has enough privileges to perform a given request.
|
||||
|
||||
Write a response back to the client if any issue is observed, and the HTTP status may be:
|
||||
@@ -1502,12 +1556,17 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
* a client certificate is expected by the server, but is missing in the request.
|
||||
|
||||
:param rh: the request which access should be checked.
|
||||
:param allowlist_check_members: whether we should check the source ip against existing cluster members.
|
||||
|
||||
:returns: ``True`` if client access verification succeeded, otherwise ``None``.
|
||||
"""
|
||||
if self.__allowlist or self.__allowlist_include_members:
|
||||
allowlist_check_members = allowlist_check_members and bool(self.__allowlist_include_members)
|
||||
if self.__allowlist or allowlist_check_members:
|
||||
incoming_ip = ip_address(rh.client_address[0])
|
||||
if not any(incoming_ip in net for net in self.__allowlist + tuple(self.__members_ips())):
|
||||
|
||||
members_ips = tuple(self.__members_ips()) if allowlist_check_members else ()
|
||||
|
||||
if not any(incoming_ip in net for net in self.__allowlist + members_ips):
|
||||
return rh.write_response(403, 'Access is denied')
|
||||
|
||||
if not hasattr(rh.request, 'getpeercert') or not rh.request.getpeercert(): # valid client cert isn't present
|
||||
@@ -1553,13 +1612,16 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
if hostname in ('', '*'):
|
||||
hostname = None
|
||||
|
||||
info = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
|
||||
# Filter out unexpected results when python is compiled with --disable-ipv6 and running on IPv6 system.
|
||||
info = [(a[0], a[4][0], a[4][1])
|
||||
for a in socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
|
||||
if isinstance(a[4][0], str) and isinstance(a[4][1], int)]
|
||||
# in case dual stack is not supported we want IPv4 to be preferred over IPv6
|
||||
info.sort(key=lambda x: x[0] == socket.AF_INET, reverse=not dual_stack)
|
||||
|
||||
self.address_family = info[0][0]
|
||||
try:
|
||||
HTTPServer.__init__(self, info[0][-1][:2], RestApiHandler)
|
||||
HTTPServer.__init__(self, (info[0][1], info[0][2]), RestApiHandler)
|
||||
except socket.error:
|
||||
logger.error(
|
||||
"Couldn't start a service on '%s:%s', please check your `restapi.listen` configuration", hostname, port)
|
||||
@@ -1676,12 +1738,11 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
|
||||
:returns: serial number of the certificate configured through ``restapi.certfile`` setting.
|
||||
"""
|
||||
if self.__ssl_options.get('certfile'):
|
||||
certfile: Optional[str] = self.__ssl_options.get('certfile')
|
||||
if certfile:
|
||||
import ssl
|
||||
try:
|
||||
crt: Dict[str, Any] = ssl._ssl._test_decode_cert(self.__ssl_options['certfile']) # pyright: ignore
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(crt, dict)
|
||||
crt = cast(Dict[str, Any], ssl._ssl._test_decode_cert(certfile)) # pyright: ignore
|
||||
return crt.get('serialNumber')
|
||||
except ssl.SSLError as e:
|
||||
logger.error('Failed to get serial number from certificate %s: %r', self.__ssl_options['certfile'], e)
|
||||
|
||||
+49
-4
@@ -1,9 +1,10 @@
|
||||
"""Patroni custom object types somewhat like :mod:`collections` module.
|
||||
|
||||
Provides a case insensitive :class:`dict` and :class:`set` object types.
|
||||
Provides a case insensitive :class:`dict` and :class:`set` object types, and `EMPTY_DICT` frozen dictionary object.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Collection, Dict, Iterator, KeysView, MutableMapping, MutableSet, Optional
|
||||
from copy import deepcopy
|
||||
from typing import Any, Collection, Dict, Iterator, KeysView, Mapping, MutableMapping, MutableSet, Optional
|
||||
|
||||
|
||||
class CaseInsensitiveSet(MutableSet[str]):
|
||||
@@ -48,7 +49,7 @@ class CaseInsensitiveSet(MutableSet[str]):
|
||||
"""
|
||||
return str(set(self._values.values()))
|
||||
|
||||
def __contains__(self, value: str) -> bool:
|
||||
def __contains__(self, value: object) -> bool:
|
||||
"""Check if set contains *value*.
|
||||
|
||||
The check is performed case-insensitively.
|
||||
@@ -57,7 +58,7 @@ class CaseInsensitiveSet(MutableSet[str]):
|
||||
|
||||
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
|
||||
"""
|
||||
return value.lower() in self._values
|
||||
return isinstance(value, str) and value.lower() in self._values
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
"""Iterate over the values in this set.
|
||||
@@ -207,3 +208,47 @@ class CaseInsensitiveDict(MutableMapping[str, Any]):
|
||||
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
|
||||
"""
|
||||
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
|
||||
|
||||
|
||||
class _FrozenDict(Mapping[str, Any]):
|
||||
"""Frozen dictionary object."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Create a new instance of :class:`_FrozenDict` with given data."""
|
||||
self.__values: Dict[str, Any] = dict(*args, **kwargs)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
"""Iterate over keys of this dict.
|
||||
|
||||
:yields: each key present in the dict. Yields each key with its last case that has been stored.
|
||||
"""
|
||||
return iter(self.__values)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get the length of this dict.
|
||||
|
||||
:returns: number of keys in the dict.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> len(_FrozenDict())
|
||||
0
|
||||
"""
|
||||
return len(self.__values)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get the value corresponding to *key*.
|
||||
|
||||
:returns: value corresponding to *key*.
|
||||
"""
|
||||
return self.__values[key]
|
||||
|
||||
def copy(self) -> Dict[str, Any]:
|
||||
"""Create a copy of this dict.
|
||||
|
||||
:return: a new dict object with the same keys and values of this dict.
|
||||
"""
|
||||
return deepcopy(self.__values)
|
||||
|
||||
|
||||
EMPTY_DICT = _FrozenDict()
|
||||
|
||||
+66
-215
@@ -2,22 +2,24 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
|
||||
from typing import Any, Callable, cast, Collection, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
import yaml
|
||||
|
||||
from . import PATRONI_ENV_PREFIX
|
||||
from .collections import CaseInsensitiveDict
|
||||
from .dcs import ClusterConfig, Cluster
|
||||
from .collections import CaseInsensitiveDict, EMPTY_DICT
|
||||
from .dcs import ClusterConfig
|
||||
from .exceptions import ConfigParseError
|
||||
from .file_perm import pg_perm
|
||||
from .postgresql.config import ConfigHandler
|
||||
from .validator import IntValidator
|
||||
from .utils import deep_compare, parse_bool, parse_int, patch_config
|
||||
from .validator import IntValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +34,8 @@ _AUTH_ALLOWED_PARAMETERS = (
|
||||
'sslcrl',
|
||||
'sslcrldir',
|
||||
'gssencmode',
|
||||
'channel_binding'
|
||||
'channel_binding',
|
||||
'sslnegotiation'
|
||||
)
|
||||
|
||||
|
||||
@@ -54,154 +57,6 @@ def default_validator(conf: Dict[str, Any]) -> List[str]:
|
||||
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.
|
||||
|
||||
@@ -290,10 +145,10 @@ class Config(object):
|
||||
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
|
||||
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
|
||||
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
|
||||
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
|
||||
self._load_cache()
|
||||
if validator: # patronictl uses validator=None
|
||||
self._load_cache() # we don't want to load anything from local cache for ctl
|
||||
self._validate_contradictory_tags() # irrelevant for ctl
|
||||
self._cache_needs_saving = False
|
||||
self._validate_failover_tags()
|
||||
|
||||
@property
|
||||
def config_file(self) -> Optional[str]:
|
||||
@@ -333,6 +188,7 @@ class Config(object):
|
||||
|
||||
:raises:
|
||||
:class:`ConfigParseError`: if *path* is invalid.
|
||||
:class:`ConfigParseError`: if *path* does not contain dict (empty file or no mapping values).
|
||||
"""
|
||||
if os.path.isfile(path):
|
||||
files = [path]
|
||||
@@ -347,7 +203,10 @@ class Config(object):
|
||||
for fname in files:
|
||||
with open(fname) as f:
|
||||
config = yaml.safe_load(f)
|
||||
patch_config(overall_config, config)
|
||||
if not isinstance(config, dict):
|
||||
logger.error('%s does not contain a dict', fname)
|
||||
raise ConfigParseError(f'invalid config file {fname}')
|
||||
patch_config(overall_config, cast(Dict[Any, Any], config))
|
||||
return overall_config
|
||||
|
||||
def _load_config_file(self) -> Dict[str, Any]:
|
||||
@@ -504,6 +363,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_contradictory_tags()
|
||||
return True
|
||||
else:
|
||||
logger.info('No local configuration items changed.')
|
||||
@@ -534,7 +394,6 @@ class Config(object):
|
||||
* ``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.
|
||||
@@ -591,14 +450,14 @@ class Config(object):
|
||||
|
||||
for name, value in dynamic_configuration.items():
|
||||
if name == 'postgresql':
|
||||
for name, value in (value or {}).items():
|
||||
for name, value in (value or EMPTY_DICT).items():
|
||||
if name == 'parameters':
|
||||
config['postgresql'][name].update(self._process_postgresql_parameters(value))
|
||||
elif name not in ('connect_address', 'proxy_address', 'listen',
|
||||
'config_dir', 'data_dir', 'pgpass', 'authentication'):
|
||||
config['postgresql'][name] = deepcopy(value)
|
||||
elif name == 'standby_cluster':
|
||||
for name, value in (value or {}).items():
|
||||
for name, value in (value or EMPTY_DICT).items():
|
||||
if name in self.__DEFAULT_CONFIG['standby_cluster']:
|
||||
config['standby_cluster'][name] = deepcopy(value)
|
||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
|
||||
@@ -681,8 +540,9 @@ class Config(object):
|
||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
|
||||
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
||||
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
|
||||
'dir', 'file_size', 'file_num', 'loggers'])
|
||||
_set_section_values('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
|
||||
'max_queue_size', 'dir', 'mode', 'file_size', 'file_num', 'loggers',
|
||||
'deduplicate_heartbeat_logs'])
|
||||
_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'):
|
||||
@@ -691,7 +551,8 @@ class Config(object):
|
||||
ret['postgresql'].setdefault('bin_name', {})[binary] = value
|
||||
|
||||
# parse all values retrieved from the environment as Python objects, according to the expected type
|
||||
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
|
||||
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure'),
|
||||
('log', 'deduplicate_heartbeat_logs')):
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_bool(value)
|
||||
@@ -699,7 +560,7 @@ class Config(object):
|
||||
ret[first][second] = value
|
||||
|
||||
for first, params in (('restapi', ('request_queue_size',)),
|
||||
('log', ('max_queue_size', 'file_size', 'file_num'))):
|
||||
('log', ('max_queue_size', 'file_size', 'file_num', 'mode'))):
|
||||
for second in params:
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
@@ -729,6 +590,12 @@ class Config(object):
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
logformat = ret.get('log', {}).get('format')
|
||||
if logformat and not re.search(r'%\(\w+\)', logformat):
|
||||
logformat = _parse_list(logformat)
|
||||
if logformat:
|
||||
ret['log']['format'] = logformat
|
||||
|
||||
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse an YAML dictionary *value* as a :class:`dict`.
|
||||
|
||||
@@ -744,7 +611,12 @@ class Config(object):
|
||||
logger.exception('Exception when parsing dict %s', value)
|
||||
return None
|
||||
|
||||
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
|
||||
dict_configs = (
|
||||
('restapi', ('http_extra_headers', 'https_extra_headers')),
|
||||
('log', ('static_fields', 'loggers'))
|
||||
)
|
||||
|
||||
for first, params in dict_configs:
|
||||
for second in params:
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
@@ -791,7 +663,7 @@ class Config(object):
|
||||
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
|
||||
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
|
||||
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
|
||||
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
|
||||
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL', 'AUTH_DATA', 'BOOTSTRAP_LABELS') and name:
|
||||
value = os.environ.pop(param)
|
||||
if name == 'CITUS':
|
||||
if suffix == 'GROUP':
|
||||
@@ -802,7 +674,7 @@ class Config(object):
|
||||
value = value and parse_int(value)
|
||||
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
|
||||
value = value and _parse_list(value)
|
||||
elif suffix in ('LABELS', 'SET_ACLS'):
|
||||
elif suffix in ('LABELS', 'SET_ACLS', 'AUTH_DATA', 'BOOTSTRAP_LABELS'):
|
||||
value = _parse_dict(value)
|
||||
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
|
||||
value = parse_bool(value)
|
||||
@@ -812,23 +684,6 @@ class Config(object):
|
||||
if dcs in ret:
|
||||
ret[dcs].update(_get_auth(dcs))
|
||||
|
||||
users = {}
|
||||
for param in list(os.environ.keys()):
|
||||
if param.startswith(PATRONI_ENV_PREFIX):
|
||||
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) # replace "_PASSWORD" with "_OPTIONS"
|
||||
options = options and _parse_list(options)
|
||||
if options:
|
||||
users[name]['options'] = options
|
||||
if users:
|
||||
ret['bootstrap']['users'] = users
|
||||
|
||||
return ret
|
||||
|
||||
def _build_effective_configuration(self, dynamic_configuration: Dict[str, Any],
|
||||
@@ -846,8 +701,8 @@ class Config(object):
|
||||
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'), int)\
|
||||
and isinstance(value.get('database'), str):
|
||||
if isinstance(value, dict) and isinstance(cast(Dict[str, Any], value).get('group'), int) \
|
||||
and isinstance(cast(Dict[str, Any], value).get('database'), str):
|
||||
config[name] = value
|
||||
elif name == 'postgresql':
|
||||
for name, value in (value or {}).items():
|
||||
@@ -892,7 +747,7 @@ class Config(object):
|
||||
if 'citus' in config:
|
||||
bootstrap = config.setdefault('bootstrap', {})
|
||||
dcs = bootstrap.setdefault('dcs', {})
|
||||
dcs.setdefault('synchronous_mode', True)
|
||||
dcs.setdefault('synchronous_mode', 'quorum')
|
||||
|
||||
updated_fields = (
|
||||
'name',
|
||||
@@ -949,35 +804,31 @@ class Config(object):
|
||||
"""
|
||||
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.
|
||||
def _validate_contradictory_tags(self) -> None:
|
||||
"""Check boolean/priority tags' 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.
|
||||
To preserve sanity (and backwards compatibility) the ``nofailover``/``nosync`` tag will still exist.
|
||||
A contradictory configuration is one where ``nofailover``/``nosync`` is ``True`` but
|
||||
``failover_priority > 0``/``sync_priority > 0``, or where ``nofailover``/``nosync`` is ``False``,
|
||||
but ``failover_priority <= 0``/``sync_priority <= 0``. Essentially, ``nofailover``/``nosync`` and
|
||||
``failover_priority``/``sync_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)
|
||||
The behaviour is as if ``failover_priority``/``sync_priority`` were not provided
|
||||
(i.e ``nofailover``/``nosync`` is the bedrock source of truth).
|
||||
"""
|
||||
tags = self.get('tags', {})
|
||||
nofailover_tag = tags.get('nofailover')
|
||||
failover_priority_tag = parse_int(tags.get('failover_priority'))
|
||||
if failover_priority_tag is not None \
|
||||
and (nofailover_tag is True and failover_priority_tag > 0
|
||||
or 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)
|
||||
|
||||
def validate_tag(bool_name: str, priority_name: str) -> None:
|
||||
if bool_name not in tags:
|
||||
return
|
||||
bool_tag = tags.get(bool_name)
|
||||
priority_tag = parse_int(tags.get(priority_name))
|
||||
if priority_tag is not None \
|
||||
and (bool(bool_tag) is True and priority_tag > 0
|
||||
or bool(bool_tag) is False and priority_tag <= 0):
|
||||
logger.warning('Conflicting configuration between %s: %s and %s: %s. Defaulting to %s: %s',
|
||||
bool_name, bool_tag, priority_name, priority_tag, bool_name, bool_tag)
|
||||
|
||||
validate_tag('nofailover', 'failover_priority')
|
||||
validate_tag('nosync', 'sync_priority')
|
||||
|
||||
+41
-28
@@ -2,19 +2,22 @@
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from contextlib import contextmanager
|
||||
from getpass import getpass, getuser
|
||||
from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
import psutil
|
||||
import yaml
|
||||
|
||||
from getpass import getuser, getpass
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Cursor
|
||||
from psycopg2 import cursor
|
||||
|
||||
from . import psycopg
|
||||
from .collections import EMPTY_DICT
|
||||
from .config import Config
|
||||
from .exceptions import PatroniException
|
||||
from .log import PatroniLogger
|
||||
@@ -22,7 +25,6 @@ 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).
|
||||
@@ -37,7 +39,8 @@ _AUTH_ALLOWED_PARAMETERS_MAPPING = {
|
||||
'sslcrl': 'PGSSLCRL',
|
||||
'sslcrldir': 'PGSSLCRLDIR',
|
||||
'gssencmode': 'PGGSSENCMODE',
|
||||
'channel_binding': 'PGCHANNELBINDING'
|
||||
'channel_binding': 'PGCHANNELBINDING',
|
||||
'sslnegotiation': 'PGSSLNEGOTIATION'
|
||||
}
|
||||
NO_VALUE_MSG = '#FIXME'
|
||||
|
||||
@@ -51,13 +54,15 @@ def get_address() -> Tuple[str, str]:
|
||||
: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`.
|
||||
If an exception occurred, 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]
|
||||
# Filter out unexpected results when python is compiled with --disable-ipv6 and running on IPv6 system.
|
||||
addrs = [(a[0], a[4][0]) for a in socket.getaddrinfo(hostname, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0)
|
||||
if isinstance(a[4][0], str)]
|
||||
return hostname, sorted(addrs, key=lambda x: x[0])[0][1]
|
||||
except Exception as err:
|
||||
logging.warning('Failed to obtain address: %r', err)
|
||||
return NO_VALUE_MSG, NO_VALUE_MSG
|
||||
@@ -71,8 +76,6 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
: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.
|
||||
|
||||
@@ -91,14 +94,17 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
: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.
|
||||
"""
|
||||
_HOSTNAME, _IP = get_address()
|
||||
|
||||
template_config: Dict[str, Any] = {
|
||||
'scope': NO_VALUE_MSG,
|
||||
'name': cls._HOSTNAME,
|
||||
'name': _HOSTNAME,
|
||||
'restapi': {
|
||||
'connect_address': cls._IP + ':8008',
|
||||
'listen': cls._IP + ':8008'
|
||||
'connect_address': _IP + ':8008',
|
||||
'listen': _IP + ':8008'
|
||||
},
|
||||
'log': {
|
||||
'type': PatroniLogger.DEFAULT_TYPE,
|
||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
@@ -106,8 +112,8 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
},
|
||||
'postgresql': {
|
||||
'data_dir': NO_VALUE_MSG,
|
||||
'connect_address': cls._IP + ':5432',
|
||||
'listen': cls._IP + ':5432',
|
||||
'connect_address': _IP + ':5432',
|
||||
'listen': _IP + ':5432',
|
||||
'bin_dir': '',
|
||||
'authentication': {
|
||||
'superuser': {
|
||||
@@ -122,9 +128,11 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
},
|
||||
'tags': {
|
||||
'failover_priority': 1,
|
||||
'sync_priority': 1,
|
||||
'noloadbalance': False,
|
||||
'clonefrom': True,
|
||||
'nosync': False,
|
||||
'nostream': False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +186,7 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
|
||||
:yields: formatted lines or blocks that represent a text output of the YAML document.
|
||||
"""
|
||||
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl' 'citus',
|
||||
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
|
||||
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
|
||||
yield from self._format_config_section(name)
|
||||
|
||||
@@ -224,7 +232,7 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
class SampleConfigGenerator(AbstractConfigGenerator):
|
||||
"""Object representing the generated sample Patroni config.
|
||||
|
||||
Sane defults are used based on the gathered PG version.
|
||||
Sane defaults are used based on the gathered PG version.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -242,7 +250,8 @@ class SampleConfigGenerator(AbstractConfigGenerator):
|
||||
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')
|
||||
postgres_bin = ((self.config.get('postgresql')
|
||||
or EMPTY_DICT).get('bin_name') or EMPTY_DICT).get('postgres', 'postgres')
|
||||
return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin))
|
||||
|
||||
def generate(self) -> None:
|
||||
@@ -264,7 +273,8 @@ class SampleConfigGenerator(AbstractConfigGenerator):
|
||||
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
|
||||
self.config['bootstrap']['dcs']['postgresql']['use_pg_rewind'] = \
|
||||
parse_bool(self.config['bootstrap']['dcs']['postgresql']['parameters']['wal_log_hints']) is True
|
||||
if self.pg_major >= 110000:
|
||||
self.config['postgresql']['authentication'].setdefault(
|
||||
'rewind', {'username': 'rewind_user'}).setdefault('password', NO_VALUE_MSG)
|
||||
@@ -307,7 +317,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
|
||||
@property
|
||||
def _required_pg_params(self) -> List[str]:
|
||||
"""PG configuration prameters that have to be always present in the generated config.
|
||||
"""PG configuration parameters that have to be always present in the generated config.
|
||||
|
||||
:returns: list of the parameter names.
|
||||
"""
|
||||
@@ -323,7 +333,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
: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
|
||||
* :exc:`OSError` occurred during ``postmaster.pid`` file handling; or
|
||||
* the obtained postmaster pid doesn't exist.
|
||||
"""
|
||||
postmaster_pid = None
|
||||
@@ -346,7 +356,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
"""Get cursor for the PG connection established based on the stored information.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.PatroniException`: if :exc:`psycopg.Error` occured.
|
||||
:exc:`~patroni.exceptions.PatroniException`: if :exc:`psycopg.Error` occurred.
|
||||
"""
|
||||
try:
|
||||
conn = psycopg.connect(dsn=self.dsn,
|
||||
@@ -395,8 +405,9 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
else:
|
||||
self.config['bootstrap']['dcs']['postgresql']['parameters'][param] = value
|
||||
|
||||
connect_ip = self.config['postgresql']['connect_address'].rsplit(':')[0]
|
||||
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']['connect_address'] = f'{connect_ip}:{connect_port}'
|
||||
self.config['postgresql']['listen'] = f'{helper_dict["listen_addresses"]}:{helper_dict["port"]}'
|
||||
|
||||
def _set_su_params(self) -> None:
|
||||
@@ -409,8 +420,10 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
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')
|
||||
patroni_env_su_username = ((self.config.get('authentication')
|
||||
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('username')
|
||||
patroni_env_su_pwd = ((self.config.get('authentication')
|
||||
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('password')
|
||||
# because we use "username" in the config for some reason
|
||||
su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser()
|
||||
su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \
|
||||
@@ -429,7 +442,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
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.
|
||||
:exc:`~patroni.exceptions.PatroniException`: if :exc:`OSError` occurred 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:
|
||||
@@ -469,7 +482,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
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')):
|
||||
if not parse_bool(getattr(cur.connection, 'get_parameter_status')('is_superuser')):
|
||||
raise PatroniException('The provided user does not have superuser privilege')
|
||||
|
||||
self._set_pg_params(cur)
|
||||
|
||||
+275
-256
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -7,6 +7,7 @@ from __future__ import print_function
|
||||
|
||||
import abc
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -17,6 +18,8 @@ from typing import Any, Optional, Type, TYPE_CHECKING
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Create a basic argument parser with the arguments used for both patroni and raft controller daemon.
|
||||
@@ -132,8 +135,13 @@ class AbstractPatroniDaemon(abc.ABC):
|
||||
"""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.
|
||||
configuration upon receiving SIGHUP.
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
from systemd import daemon # pyright: ignore
|
||||
daemon.notify("READY=1") # pyright: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
logger.info("Systemd integration is not supported")
|
||||
self.logger.start()
|
||||
while not self.received_sigterm:
|
||||
if self._received_sighup:
|
||||
|
||||
+467
-302
File diff suppressed because it is too large
Load Diff
+49
-31
@@ -1,4 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -6,19 +7,24 @@ import re
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import urllib3
|
||||
|
||||
from collections import defaultdict
|
||||
from consul import ConsulException, NotFound, base
|
||||
from http.client import HTTPException
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Tuple, TYPE_CHECKING, Union
|
||||
from urllib.parse import quote, urlencode, urlparse
|
||||
|
||||
import urllib3
|
||||
|
||||
from consul import base, Check, ConsulException, NotFound
|
||||
from urllib3.exceptions import HTTPError
|
||||
from urllib.parse import urlencode, urlparse, quote
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.misc import PostgresqlRole, PostgresqlState
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
from . import AbstractDCS, catch_return_false_exception, Cluster, ClusterConfig, \
|
||||
Failover, Leader, Member, ReturnFalseException, Status, SyncState, TimelineHistory
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
@@ -187,7 +193,7 @@ class ConsulClient(base.Consul):
|
||||
return HTTPClient(**kwargs)
|
||||
|
||||
def connect(self, *args: Any, **kwargs: Any) -> HTTPClient:
|
||||
return self.http_connect(*args, **kwargs)
|
||||
return self.http_connect(*args, **kwargs) # pragma: no cover
|
||||
|
||||
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||
self.http.token = self.token = config.get('token')
|
||||
@@ -232,8 +238,8 @@ def service_name_from_scope_name(scope_name: str) -> str:
|
||||
|
||||
class Consul(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Consul, self).__init__(config)
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Consul, self).__init__(config, mpp)
|
||||
self._base_path = self._base_path[1:]
|
||||
self._scope = config['scope']
|
||||
self._session = None
|
||||
@@ -419,23 +425,36 @@ class Consul(AbstractDCS):
|
||||
def _consistency(self) -> str:
|
||||
return 'consistent' if self._ctl else self._client.consistency
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
if results is None:
|
||||
raise NotFound
|
||||
nodes = {}
|
||||
return Cluster.empty()
|
||||
nodes: Dict[str, Dict[str, Any]] = {}
|
||||
for node in results:
|
||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||
nodes[node['Key'][len(path):]] = node
|
||||
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
results: Optional[List[Dict[str, Any]]]
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
|
||||
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
|
||||
for node in results or []:
|
||||
key = node['Key'][len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
@@ -445,8 +464,6 @@ class Consul(AbstractDCS):
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
try:
|
||||
return loader(path)
|
||||
except NotFound:
|
||||
return Cluster.empty()
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise ConsulError('Consul is not responding properly')
|
||||
@@ -510,16 +527,14 @@ class Consul(AbstractDCS):
|
||||
api_parts = api_parts._replace(path='/{0}'.format(role))
|
||||
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))
|
||||
check = Check.http(api_parts.geturl(), self._service_check_interval,
|
||||
deregister='{0}s'.format(self._client.http.ttl * 10))
|
||||
if self._service_check_tls_server_name is not None:
|
||||
check['TLSServerName'] = self._service_check_tls_server_name
|
||||
tags = self._service_tags[:]
|
||||
tags.append(role)
|
||||
if role == 'master':
|
||||
tags.append('primary')
|
||||
elif role == 'primary':
|
||||
tags.append('master')
|
||||
if data['role'] == PostgresqlRole.PRIMARY:
|
||||
tags.append(PostgresqlRole.MASTER)
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
self._previous_loop_token = self._client.token
|
||||
|
||||
@@ -532,13 +547,13 @@ class Consul(AbstractDCS):
|
||||
'enable_tag_override': True,
|
||||
}
|
||||
|
||||
if state == 'stopped' or (not self._register_service and self._previous_loop_register_service):
|
||||
if state == PostgresqlState.STOPPED or (not self._register_service and self._previous_loop_register_service):
|
||||
self._previous_loop_register_service = self._register_service
|
||||
return self.deregister_service(params['service_id'])
|
||||
|
||||
self._previous_loop_register_service = self._register_service
|
||||
if role in ['master', 'primary', 'replica', 'standby-leader']:
|
||||
if state != 'running':
|
||||
if data['role'] in [PostgresqlRole.PRIMARY, PostgresqlRole.REPLICA, PostgresqlRole.STANDBY_LEADER]:
|
||||
if state != PostgresqlState.RUNNING:
|
||||
return
|
||||
return self.register_service(service_name, **params)
|
||||
|
||||
@@ -566,14 +581,17 @@ class Consul(AbstractDCS):
|
||||
try:
|
||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
except InvalidSession:
|
||||
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
|
||||
self._session = None
|
||||
retry.ensure_deadline(0)
|
||||
|
||||
if not retry.ensure_deadline(0):
|
||||
logger.error('Our session disappeared from Consul. Deadline exceeded, giving up')
|
||||
return False
|
||||
|
||||
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
|
||||
|
||||
retry(self._do_refresh_session)
|
||||
|
||||
retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
|
||||
|
||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||
|
||||
@catch_return_false_exception
|
||||
@@ -665,10 +683,10 @@ class Consul(AbstractDCS):
|
||||
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 ret: # We have no other choice, only read after write :(
|
||||
if not retry.ensure_deadline(0.5):
|
||||
return False
|
||||
_, ret = self.retry(self._client.kv.get, self.sync_path)
|
||||
_, ret = self.retry(self._client.kv.get, self.sync_path, consistency='consistent')
|
||||
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
|
||||
return ret['ModifyIndex']
|
||||
return False
|
||||
|
||||
+87
-27
@@ -1,31 +1,36 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
import etcd
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib3.util.connection
|
||||
import random
|
||||
import socket
|
||||
import time
|
||||
|
||||
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 typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
from urllib.parse import urlparse
|
||||
from urllib3 import Timeout
|
||||
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
import etcd
|
||||
import urllib3.util.connection
|
||||
|
||||
from dns import resolver
|
||||
from dns.exception import DNSException
|
||||
from urllib3 import Timeout
|
||||
from urllib3.exceptions import HTTPError, ProtocolError, ReadTimeoutError
|
||||
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..request import get as requests_get
|
||||
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
from . import AbstractDCS, catch_return_false_exception, Cluster, ClusterConfig, \
|
||||
Failover, Leader, Member, ReturnFalseException, Status, SyncState, TimelineHistory
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
@@ -36,11 +41,16 @@ class EtcdRaftInternal(etcd.EtcdException):
|
||||
"""Raft Internal Error"""
|
||||
|
||||
|
||||
class StaleEtcdNode(Exception):
|
||||
"""Node is stale (raft term is older than previous known)."""
|
||||
|
||||
|
||||
class EtcdError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
_AddrInfo = Tuple[socket.AddressFamily, socket.SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]]
|
||||
_AddrInfo = Tuple[socket.AddressFamily, socket.SocketKind, int, str,
|
||||
Union[Tuple[str, int], Tuple[str, int, int, int], Tuple[int, bytes]]]
|
||||
|
||||
|
||||
class DnsCachingResolver(Thread):
|
||||
@@ -96,6 +106,8 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
ERROR_CLS: Type[Exception]
|
||||
|
||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||
self._cluster_id = None
|
||||
self._raft_term = 0
|
||||
self._dns_resolver = dns_resolver
|
||||
self.set_machines_cache_ttl(cache_ttl)
|
||||
self._machines_cache_updated = 0
|
||||
@@ -113,6 +125,32 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
self._read_options.add('retry')
|
||||
self._del_conditions.add('retry')
|
||||
|
||||
def _check_cluster_raft_term(self, cluster_id: Optional[str], value: Union[None, str, int]) -> None:
|
||||
"""Check that observed Raft Term in Etcd cluster is increasing.
|
||||
|
||||
If we observe that the new value is smaller than the previously known one, it could be an
|
||||
indicator that we connected to a stale node and should switch to some other node.
|
||||
However, we need to reset the memorized value when we notice that Cluster ID changed.
|
||||
"""
|
||||
if not (cluster_id and value):
|
||||
return
|
||||
|
||||
if self._cluster_id and self._cluster_id != cluster_id:
|
||||
logger.warning('Etcd Cluster ID changed from %s to %s', self._cluster_id, cluster_id)
|
||||
self._raft_term = 0
|
||||
self._cluster_id = cluster_id
|
||||
|
||||
try:
|
||||
raft_term = int(value)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if raft_term < self._raft_term:
|
||||
logger.warning('Connected to Etcd node with term %d. Old known term %d. Switching to another node.',
|
||||
raft_term, self._raft_term)
|
||||
raise StaleEtcdNode
|
||||
self._raft_term = raft_term
|
||||
|
||||
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.
|
||||
@@ -221,7 +259,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
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:
|
||||
**kwargs: Any) -> Any:
|
||||
is_watch_request = isinstance(fields, dict) and fields.get('wait') == 'true'
|
||||
if fields is not None:
|
||||
kwargs['fields'] = fields
|
||||
@@ -235,8 +273,8 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
if some_request_failed:
|
||||
self.set_base_uri(base_uri)
|
||||
self._refresh_machines_cache()
|
||||
return response
|
||||
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
|
||||
return self._handle_server_response(response)
|
||||
except (HTTPError, HTTPException, socket.error, socket.timeout, StaleEtcdNode) as e:
|
||||
self.http.clear()
|
||||
if not retry:
|
||||
if len(machines_cache) == 1:
|
||||
@@ -279,8 +317,7 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = self._do_http_request(retry, machines_cache, request_executor, method, path, **kwargs)
|
||||
return self._handle_server_response(response)
|
||||
return self._do_http_request(retry, machines_cache, request_executor, method, path, **kwargs)
|
||||
except etcd.EtcdWatchTimedOut:
|
||||
raise
|
||||
except etcd.EtcdConnectionFailed as ex:
|
||||
@@ -345,7 +382,9 @@ class AbstractEtcdClientWithFailover(abc.ABC, etcd.Client):
|
||||
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 = [uri(self.protocol, res[-1][:2]) for res in self._dns_resolver.resolve(host, port)]
|
||||
# Filter out unexpected results when python is compiled with --disable-ipv6 and running on IPv6 system.
|
||||
ret = [uri(self.protocol, (res[4][0], res[4][1])) for res in self._dns_resolver.resolve(host, port)
|
||||
if isinstance(res[4][0], str) and isinstance(res[4][1], int)]
|
||||
if ret:
|
||||
return list(set(ret))
|
||||
return [uri(self.protocol, (host, port))]
|
||||
@@ -455,6 +494,10 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
def _prepare_get_members(self, etcd_nodes: int) -> Dict[str, Any]:
|
||||
return self._prepare_common_parameters(etcd_nodes)
|
||||
|
||||
def _handle_server_response(self, response: urllib3.response.HTTPResponse) -> Any:
|
||||
self._check_cluster_raft_term(response.headers.get('x-etcd-cluster-id'), response.headers.get('x-raft-term'))
|
||||
return super(EtcdClient, self)._handle_server_response(response)
|
||||
|
||||
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')
|
||||
@@ -470,9 +513,9 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
|
||||
class AbstractEtcd(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP, client_cls: Type[AbstractEtcdClientWithFailover],
|
||||
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
|
||||
super(AbstractEtcd, self).__init__(config)
|
||||
super(AbstractEtcd, self).__init__(config, mpp)
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=retry_errors_cls)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
@@ -645,8 +688,8 @@ def catch_etcd_errors(func: Callable[..., Any]) -> Any:
|
||||
|
||||
class Etcd(AbstractEtcd):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
||||
self.__do_not_watch = False
|
||||
|
||||
@property
|
||||
@@ -709,17 +752,36 @@ class Etcd(AbstractEtcd):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
try:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return Cluster.empty()
|
||||
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
|
||||
return self._cluster_from_nodes(result.etcd_index, nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
try:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return {}
|
||||
|
||||
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
for node in result.leaves:
|
||||
key = node.key[len(result.key):].lstrip('/').split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
|
||||
|
||||
@@ -729,8 +791,6 @@ class Etcd(AbstractEtcd):
|
||||
cluster = None
|
||||
try:
|
||||
cluster = loader(path)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
cluster = Cluster.empty()
|
||||
except Exception as e:
|
||||
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
|
||||
self._has_failed = False
|
||||
|
||||
+56
-42
@@ -1,25 +1,30 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import base64
|
||||
import etcd
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib3
|
||||
|
||||
from collections import defaultdict
|
||||
from enum import IntEnum
|
||||
from urllib3.exceptions import ReadTimeoutError, ProtocolError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, catch_return_false_exception, citus_group_re
|
||||
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
|
||||
import etcd
|
||||
import urllib3
|
||||
|
||||
from urllib3.exceptions import ProtocolError, ReadTimeoutError
|
||||
|
||||
from ..collections import EMPTY_DICT
|
||||
from ..exceptions import DCSError, PatroniException
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
|
||||
from . import catch_return_false_exception, Cluster, ClusterConfig, \
|
||||
Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
from .etcd import AbstractEtcd, AbstractEtcdClientWithFailover, catch_etcd_errors, DnsCachingResolver, Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -146,8 +151,7 @@ errStringToClientError = {getattr(s, 'error'): s for s in Etcd3ClientError.get_s
|
||||
errCodeToClientError = {getattr(s, 'code'): s for s in Etcd3ClientError.__subclasses__()}
|
||||
|
||||
|
||||
def _raise_for_data(data: Union[bytes, str, Dict[str, Union[Any, Dict[str, Any]]]],
|
||||
status_code: Optional[int] = None) -> Etcd3ClientError:
|
||||
def _raise_for_data(data: Union[bytes, str, Dict[str, Any]], status_code: Optional[int] = None) -> Etcd3ClientError:
|
||||
try:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(data, dict)
|
||||
@@ -197,12 +201,6 @@ def build_range_request(key: str, range_end: Union[bytes, str, None] = None) ->
|
||||
return fields
|
||||
|
||||
|
||||
class ReAuthenticateMode(IntEnum):
|
||||
NOT_REQUIRED = 0
|
||||
REQUIRED = 1
|
||||
WITHOUT_WATCHER_RESTART = 2
|
||||
|
||||
|
||||
def _handle_auth_errors(func: Callable[..., Any]) -> Any:
|
||||
def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any:
|
||||
return self.handle_auth_errors(func, *args, **kwargs)
|
||||
@@ -214,7 +212,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
ERROR_CLS = Etcd3Error
|
||||
|
||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||
self._reauthenticate = False
|
||||
self._token = None
|
||||
self._cluster_version: Tuple[int, ...] = tuple()
|
||||
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
|
||||
@@ -243,6 +241,10 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
try:
|
||||
data = data.decode('utf-8')
|
||||
ret: Dict[str, Any] = json.loads(data)
|
||||
|
||||
header = ret.get('header', EMPTY_DICT)
|
||||
self._check_cluster_raft_term(header.get('cluster_id'), header.get('raft_term'))
|
||||
|
||||
if response.status < 400:
|
||||
return ret
|
||||
except (TypeError, ValueError, UnicodeError) as e:
|
||||
@@ -293,7 +295,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
fields['retry'] = retry
|
||||
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
|
||||
|
||||
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
|
||||
def authenticate(self, *, retry: Optional[Retry] = None) -> bool:
|
||||
if self._use_proxies and not self._cluster_version:
|
||||
kwargs = self._prepare_common_parameters(1)
|
||||
self._ensure_version_prefix(self._base_uri, **kwargs)
|
||||
@@ -315,20 +317,18 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
|
||||
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
|
||||
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
|
||||
reauthenticated = False
|
||||
exc = None
|
||||
while True:
|
||||
if self._reauthenticate_reason:
|
||||
if self._reauthenticate:
|
||||
if self.username and self.password:
|
||||
self.authenticate(
|
||||
restart_watcher=self._reauthenticate_reason != ReAuthenticateMode.WITHOUT_WATCHER_RESTART,
|
||||
retry=retry)
|
||||
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||
if retry:
|
||||
retry.ensure_deadline(0)
|
||||
self.authenticate(retry=retry)
|
||||
self._reauthenticate = False
|
||||
else:
|
||||
msg = 'Username or password not set, authentication is not possible'
|
||||
logger.fatal(msg)
|
||||
raise exc or Etcd3Exception(msg)
|
||||
reauthenticated = True
|
||||
|
||||
try:
|
||||
return func(self, *args, retry=retry, **kwargs)
|
||||
@@ -346,11 +346,12 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
except AuthOldRevision as e:
|
||||
logger.error('Auth token is for old revision of auth store')
|
||||
exc = e
|
||||
self._reauthenticate_reason = ReAuthenticateMode.WITHOUT_WATCHER_RESTART \
|
||||
if isinstance(exc, AuthOldRevision) else ReAuthenticateMode.REQUIRED
|
||||
if not retry:
|
||||
self._reauthenticate = True
|
||||
if retry:
|
||||
logger.error('retry = %s', retry)
|
||||
retry.ensure_deadline(0.5, exc)
|
||||
elif reauthenticated:
|
||||
raise exc
|
||||
retry.ensure_deadline(0.5, exc)
|
||||
|
||||
@_handle_auth_errors
|
||||
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
|
||||
@@ -602,12 +603,6 @@ class PatroniEtcd3Client(Etcd3Client):
|
||||
super(PatroniEtcd3Client, self).set_base_uri(value)
|
||||
self._restart_watcher()
|
||||
|
||||
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
|
||||
ret = super(PatroniEtcd3Client, self).authenticate(restart_watcher=restart_watcher, retry=retry)
|
||||
if ret and restart_watcher:
|
||||
self._restart_watcher()
|
||||
return ret
|
||||
|
||||
def _wait_cache(self, timeout: float) -> None:
|
||||
stop_time = time.time() + timeout
|
||||
while self._kv_cache and not self._kv_cache.is_ready():
|
||||
@@ -671,8 +666,9 @@ class PatroniEtcd3Client(Etcd3Client):
|
||||
|
||||
class Etcd3(AbstractEtcd):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Etcd3, self).__init__(config, mpp, PatroniEtcd3Client,
|
||||
(DeadlineExceeded, Unavailable, FailedPrecondition))
|
||||
self.__do_not_watch = False
|
||||
self._lease = None
|
||||
self._last_lease_refresh = 0
|
||||
@@ -731,7 +727,11 @@ class Etcd3(AbstractEtcd):
|
||||
|
||||
@property
|
||||
def cluster_prefix(self) -> str:
|
||||
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
|
||||
"""Construct the cluster prefix for the cluster.
|
||||
|
||||
:returns: path in the DCS under which we store information about this Patroni cluster.
|
||||
"""
|
||||
return self._base_path + '/' if self.is_mpp_coordinator() else self.client_path('')
|
||||
|
||||
@staticmethod
|
||||
def member(node: Dict[str, str]) -> Member:
|
||||
@@ -785,18 +785,30 @@ class Etcd3(AbstractEtcd):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
nodes = {node['key'][len(path):]: node
|
||||
for node in self._client.get_cluster(path)
|
||||
if node['key'].startswith(path)}
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
|
||||
path = self._base_path + '/'
|
||||
for node in self._client.get_cluster(path):
|
||||
key = node['key'][len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
|
||||
@@ -848,14 +860,16 @@ class Etcd3(AbstractEtcd):
|
||||
try:
|
||||
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
|
||||
except LeaseNotFound:
|
||||
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
|
||||
self._lease = None
|
||||
retry.ensure_deadline(0)
|
||||
if not retry.ensure_deadline(0):
|
||||
logger.error('Our lease disappeared from Etcd. Deadline exceeded, giving up')
|
||||
return False
|
||||
|
||||
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
|
||||
|
||||
_retry(self._do_refresh_lease)
|
||||
|
||||
retry.ensure_deadline(1, Etcd3Error('_do_attempt_to_acquire_leader timeout'))
|
||||
|
||||
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
|
||||
|
||||
@catch_return_false_exception
|
||||
|
||||
@@ -3,12 +3,13 @@ import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from typing import Any, Callable, Dict, List, Union
|
||||
from typing import Any, Callable, cast, Dict, List, Union
|
||||
|
||||
from . import Cluster
|
||||
from .zookeeper import ZooKeeper
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..request import get as requests_get
|
||||
from ..utils import uri
|
||||
from . import Cluster
|
||||
from .zookeeper import ZooKeeper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,8 +41,9 @@ class ExhibitorEnsembleProvider(object):
|
||||
|
||||
if isinstance(json, dict) and 'servers' in json and 'port' in json:
|
||||
self._next_poll = time.time() + self._poll_interval
|
||||
servers: List[str] = json['servers']
|
||||
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(servers)])
|
||||
servers: List[str] = cast(Dict[str, Any], json)['servers']
|
||||
port = str(cast(Dict[str, Any], json)['port'])
|
||||
zookeeper_hosts = ','.join([h + ':' + 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
|
||||
@@ -49,7 +51,7 @@ class ExhibitorEnsembleProvider(object):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _query_exhibitors(self, exhibitors: List[str]) -> Union[Dict[str, Any], Any]:
|
||||
def _query_exhibitors(self, exhibitors: List[str]) -> Any:
|
||||
random.shuffle(exhibitors)
|
||||
for host in exhibitors:
|
||||
try:
|
||||
@@ -66,10 +68,10 @@ class ExhibitorEnsembleProvider(object):
|
||||
|
||||
class Exhibitor(ZooKeeper):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
interval = config.get('poll_interval', 300)
|
||||
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
|
||||
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
|
||||
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}, mpp)
|
||||
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
|
||||
+100
-56
@@ -9,21 +9,26 @@ import random
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
import urllib3
|
||||
import yaml
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from http.client import HTTPException
|
||||
from urllib3.exceptions import HTTPError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
|
||||
import urllib3
|
||||
import yaml
|
||||
|
||||
from urllib3.exceptions import HTTPError
|
||||
|
||||
from ..collections import EMPTY_DICT
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
|
||||
Retry, RetryFailedError, tzutc, uri, USER_AGENT
|
||||
from ..postgresql.misc import PostgresqlRole, PostgresqlState
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare, iter_response_objects, \
|
||||
keepalive_socket_options, Retry, RetryFailedError, tzutc, uri, USER_AGENT
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
@@ -470,7 +475,7 @@ class K8sClient(object):
|
||||
if len(args) == 3: # name, namespace, body
|
||||
body = args[2]
|
||||
elif action == 'create': # namespace, body
|
||||
body = args[1]
|
||||
body = args[1] # pyright: ignore [reportGeneralTypeIssues]
|
||||
elif action == 'delete': # name, namespace
|
||||
body = kwargs.pop('body', None)
|
||||
else:
|
||||
@@ -509,7 +514,7 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
|
||||
@property
|
||||
def sleeptime(self) -> Optional[int]:
|
||||
try:
|
||||
return int((self.headers or {}).get('retry-after', ''))
|
||||
return int((self.headers or EMPTY_DICT).get('retry-after', ''))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -644,7 +649,7 @@ class ObjectCache(Thread):
|
||||
with self._object_cache_lock:
|
||||
return self._object_cache.get(name)
|
||||
|
||||
def _process_event(self, event: Dict[str, Union[Any, Dict[str, Union[Any, Dict[str, Any]]]]]) -> None:
|
||||
def _process_event(self, event: Dict[str, Any]) -> None:
|
||||
ev_type = event['type']
|
||||
obj = event['object']
|
||||
name = obj['metadata']['name']
|
||||
@@ -654,7 +659,7 @@ class ObjectCache(Thread):
|
||||
obj = K8sObject(obj)
|
||||
success, old_value = self.set(name, obj)
|
||||
if success:
|
||||
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
|
||||
new_value = (obj.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
|
||||
elif ev_type == 'DELETED':
|
||||
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
|
||||
else:
|
||||
@@ -662,7 +667,7 @@ class ObjectCache(Thread):
|
||||
|
||||
if success and obj.get('kind') != 'Pod':
|
||||
if old_value:
|
||||
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
|
||||
old_value = (old_value.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
|
||||
|
||||
value_changed = old_value != new_value and \
|
||||
(name != self._dcs.config_path or old_value is not None and new_value is not None)
|
||||
@@ -746,22 +751,22 @@ class ObjectCache(Thread):
|
||||
|
||||
class Kubernetes(AbstractDCS):
|
||||
|
||||
_CITUS_LABEL = 'citus-group'
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
self._labels = deepcopy(config['labels'])
|
||||
self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
|
||||
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
|
||||
self._namespace = config.get('namespace') or 'default'
|
||||
self._role_label = config.get('role_label', 'role')
|
||||
self._leader_label_value = config.get('leader_label_value', 'master')
|
||||
self._leader_label_value = config.get('leader_label_value', 'primary')
|
||||
self._follower_label_value = config.get('follower_label_value', 'replica')
|
||||
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
|
||||
self._standby_leader_label_value = config.get('standby_leader_label_value', 'primary')
|
||||
self._tmp_role_label = config.get('tmp_role_label')
|
||||
self._bootstrap_labels: Dict[str, str] = {str(k): str(v)
|
||||
for k, v in (config.get('bootstrap_labels') or EMPTY_DICT).items()}
|
||||
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||
super(Kubernetes, self).__init__({**config, 'namespace': ''})
|
||||
if self._citus_group:
|
||||
self._labels[self._CITUS_LABEL] = self._citus_group
|
||||
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
|
||||
if self._mpp.is_enabled():
|
||||
self._labels[self._mpp.k8s_group_label] = str(self._mpp.group)
|
||||
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=KubernetesRetriableException)
|
||||
@@ -846,7 +851,7 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
@staticmethod
|
||||
def member(pod: K8sObject) -> Member:
|
||||
annotations = pod.metadata.annotations or {}
|
||||
annotations = pod.metadata.annotations or EMPTY_DICT
|
||||
member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
|
||||
member.data['pod_labels'] = pod.metadata.labels
|
||||
return member
|
||||
@@ -927,7 +932,7 @@ class Kubernetes(AbstractDCS):
|
||||
failover = nodes.get(path + self._FAILOVER)
|
||||
metadata = failover and failover.metadata
|
||||
failover = metadata and Failover.from_node(metadata.resource_version,
|
||||
(metadata.annotations or {}).copy())
|
||||
(metadata.annotations or EMPTY_DICT).copy())
|
||||
|
||||
# get synchronization state
|
||||
sync = nodes.get(path + self._SYNC)
|
||||
@@ -936,20 +941,32 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: Dict[str, Any]) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
|
||||
|
||||
def _citus_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))
|
||||
|
||||
for name, pod in path['pods'].items():
|
||||
group = pod.metadata.labels.get(self._CITUS_LABEL)
|
||||
if group and citus_group_re.match(group):
|
||||
group = pod.metadata.labels.get(self._mpp.k8s_group_label)
|
||||
if group and self._mpp.group_re.match(group):
|
||||
clusters[group]['pods'][name] = pod
|
||||
|
||||
for name, kind in path['nodes'].items():
|
||||
group = kind.metadata.labels.get(self._CITUS_LABEL)
|
||||
if group and citus_group_re.match(group):
|
||||
group = kind.metadata.labels.get(self._mpp.k8s_group_label)
|
||||
if group and self._mpp.group_re.match(group):
|
||||
clusters[group]['nodes'][name] = kind
|
||||
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
|
||||
for group, value in clusters.items()}
|
||||
@@ -965,9 +982,9 @@ class Kubernetes(AbstractDCS):
|
||||
with self._condition:
|
||||
self._wait_caches(stop_time)
|
||||
pods = {name: pod for name, pod in self._pods.copy().items()
|
||||
if not group or pod.metadata.labels.get(self._CITUS_LABEL) == group}
|
||||
if not group or pod.metadata.labels.get(self._mpp.k8s_group_label) == group}
|
||||
nodes = {name: kind for name, kind in self._kinds.copy().items()
|
||||
if not group or kind.metadata.labels.get(self._CITUS_LABEL) == group}
|
||||
if not group or kind.metadata.labels.get(self._mpp.k8s_group_label) == group}
|
||||
return loader({'group': group, 'pods': pods, 'nodes': nodes})
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
@@ -976,17 +993,24 @@ class Kubernetes(AbstractDCS):
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
group = self._citus_group if path == self.client_path('') else None
|
||||
group = str(self._mpp.group) if self._mpp.is_enabled() and path == self.client_path('') else None
|
||||
return self.__load_cluster(group, loader)
|
||||
|
||||
def get_citus_coordinator(self) -> Optional[Cluster]:
|
||||
def get_mpp_coordinator(self) -> Optional[Cluster]:
|
||||
"""Load the PostgreSQL cluster for the MPP Coordinator.
|
||||
|
||||
.. note::
|
||||
This method is only executed on the worker nodes to find the coordinator.
|
||||
|
||||
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
|
||||
"""
|
||||
try:
|
||||
ret = self.__load_cluster(str(CITUS_COORDINATOR_GROUP_ID), self._cluster_loader)
|
||||
ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._postgresql_cluster_loader)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(ret, Cluster)
|
||||
return ret
|
||||
except Exception as e:
|
||||
logger.error('Failed to load Citus coordinator cluster from Kubernetes: %r', e)
|
||||
logger.error('Failed to load %s coordinator cluster from Kubernetes: %r', self._mpp.type, e)
|
||||
|
||||
@staticmethod
|
||||
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
|
||||
@@ -1029,9 +1053,10 @@ class Kubernetes(AbstractDCS):
|
||||
return False
|
||||
|
||||
def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject:
|
||||
# we want to re-use existing target_ref if possible
|
||||
# we want to reuse existing target_ref if possible
|
||||
empty_addresses: List[K8sObject] = []
|
||||
for subset in latest_subsets:
|
||||
for address in subset.addresses or []:
|
||||
for address in subset.addresses or empty_addresses:
|
||||
if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name:
|
||||
return address.target_ref
|
||||
return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace,
|
||||
@@ -1039,7 +1064,8 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None:
|
||||
leader = self._kinds.get(self.leader_path)
|
||||
latest_subsets = leader and leader.subsets or []
|
||||
empty_addresses: List[K8sObject] = []
|
||||
latest_subsets = leader and leader.subsets or empty_addresses
|
||||
if not ips:
|
||||
# We want to have subsets empty
|
||||
if latest_subsets:
|
||||
@@ -1192,23 +1218,28 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
self._kinds.set(self.leader_path, kind)
|
||||
|
||||
if not retry.ensure_deadline(0.5):
|
||||
return False
|
||||
|
||||
kind_annotations = kind and kind.metadata.annotations or {}
|
||||
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
|
||||
kind_resource_version = kind and kind.metadata.resource_version
|
||||
|
||||
# There is different leader or resource_version in cache didn't change
|
||||
if kind and (kind_annotations.get(self._LEADER) != self._name or kind_resource_version == resource_version):
|
||||
return False
|
||||
|
||||
# We can get 409 because we do at least one retry, and the first update might have succeeded,
|
||||
# therefore we will check if annotations on the read object match expectations.
|
||||
if all(kind_annotations.get(k) == v for k, v in annotations.items()):
|
||||
return True
|
||||
|
||||
if not retry.ensure_deadline(0.5):
|
||||
return False
|
||||
|
||||
return bool(_run_and_handle_exceptions(self._patch_or_create, self.leader_path, annotations,
|
||||
kind_resource_version, ips=ips, retry=_retry))
|
||||
|
||||
def update_leader(self, leader: Leader, last_lsn: Optional[int],
|
||||
def update_leader(self, cluster: Cluster, last_lsn: Optional[int],
|
||||
slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool:
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
kind_annotations = kind and kind.metadata.annotations or {}
|
||||
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
|
||||
|
||||
if kind and kind_annotations.get(self._LEADER) != self._name:
|
||||
return False
|
||||
@@ -1221,6 +1252,8 @@ class Kubernetes(AbstractDCS):
|
||||
if last_lsn:
|
||||
annotations[self._OPTIME] = str(last_lsn)
|
||||
annotations['slots'] = json.dumps(slots, separators=(',', ':')) if slots else None
|
||||
retain_slots = self._build_retain_slots(cluster, slots)
|
||||
annotations['retain_slots'] = json.dumps(retain_slots) if retain_slots else None
|
||||
|
||||
if failsafe is not None:
|
||||
annotations[self._FAILSAFE] = json.dumps(failsafe, separators=(',', ':')) if failsafe else None
|
||||
@@ -1286,28 +1319,36 @@ class Kubernetes(AbstractDCS):
|
||||
def touch_member(self, data: Dict[str, Any]) -> bool:
|
||||
cluster = self.cluster
|
||||
if cluster and cluster.leader and cluster.leader.name == self._name:
|
||||
role = self._standby_leader_label_value if data['role'] == 'standby_leader' else self._leader_label_value
|
||||
tmp_role = 'master'
|
||||
elif data['state'] == 'running' and data['role'] not in ('master', 'primary'):
|
||||
role = self._standby_leader_label_value \
|
||||
if data['role'] == PostgresqlRole.STANDBY_LEADER else self._leader_label_value
|
||||
tmp_role = 'primary'
|
||||
elif data['state'] == PostgresqlState.RUNNING and data['role'] != PostgresqlRole.PRIMARY:
|
||||
role = {'replica': self._follower_label_value}.get(data['role'], data['role'])
|
||||
tmp_role = data['role']
|
||||
else:
|
||||
role = None
|
||||
tmp_role = None
|
||||
|
||||
role_labels = {self._role_label: role}
|
||||
updated_labels = {self._role_label: role}
|
||||
if self._tmp_role_label:
|
||||
role_labels[self._tmp_role_label] = tmp_role
|
||||
updated_labels[self._tmp_role_label] = tmp_role
|
||||
|
||||
if self._bootstrap_labels:
|
||||
if data['state'] in (PostgresqlState.INITDB, PostgresqlState.CUSTOM_BOOTSTRAP,
|
||||
PostgresqlState.BOOTSTRAP_STARTING, PostgresqlState.CREATING_REPLICA):
|
||||
updated_labels.update(self._bootstrap_labels)
|
||||
else:
|
||||
updated_labels.update({k: None for k, _ in self._bootstrap_labels.items()})
|
||||
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
pod_labels = member and member.data.pop('pod_labels', None)
|
||||
ret = member and pod_labels is not None\
|
||||
and all(pod_labels.get(k) == v for k, v in role_labels.items())\
|
||||
and all(pod_labels.get(k) == v for k, v in updated_labels.items())\
|
||||
and deep_compare(data, member.data)
|
||||
|
||||
if not ret:
|
||||
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': role_labels,
|
||||
'annotations': {'status': json.dumps(data, separators=(',', ':'))}}
|
||||
metadata: Dict[str, Any] = {'namespace': self._namespace, 'name': self._name, 'labels': updated_labels,
|
||||
'annotations': {'status': json.dumps(data, separators=(',', ':'))}}
|
||||
body = k8s_client.V1Pod(metadata=k8s_client.V1ObjectMeta(**metadata))
|
||||
ret = self._api.patch_namespaced_pod(self._name, self._namespace, body)
|
||||
if ret:
|
||||
@@ -1329,7 +1370,7 @@ class Kubernetes(AbstractDCS):
|
||||
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
|
||||
ret = False
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
|
||||
if kind and (kind.metadata.annotations or EMPTY_DICT).get(self._LEADER) == self._name:
|
||||
annotations: Dict[str, Optional[str]] = {self._LEADER: None}
|
||||
if last_lsn:
|
||||
annotations[self._OPTIME] = str(last_lsn)
|
||||
@@ -1353,15 +1394,18 @@ class Kubernetes(AbstractDCS):
|
||||
raise NotImplementedError # pragma: no cover
|
||||
|
||||
def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
|
||||
version: Optional[str] = None) -> Optional[SyncState]:
|
||||
quorum: Optional[int], version: Optional[str] = None) -> Optional[SyncState]:
|
||||
"""Prepare and write annotations to $SCOPE-sync Endpoint or ConfigMap.
|
||||
|
||||
:param leader: name of the leader node that manages /sync key
|
||||
:param sync_standby: collection of currently known synchronous standby node names
|
||||
:param quorum: if the node from sync_standby list is doing a leader race it should
|
||||
see at least quorum other nodes from the sync_standby + leader list
|
||||
:param version: last known `resource_version` for conditional update of the object
|
||||
:returns: the new :class:`SyncState` object or None
|
||||
"""
|
||||
sync_state = self.sync_state(leader, sync_standby)
|
||||
sync_state = self.sync_state(leader, sync_standby, quorum)
|
||||
sync_state['quorum'] = str(sync_state['quorum']) if sync_state['quorum'] is not None else None
|
||||
ret = self.patch_or_create(self.sync_path, sync_state, version, False)
|
||||
if not isinstance(ret, bool):
|
||||
return SyncState.from_node(ret.metadata.resource_version, sync_state)
|
||||
@@ -1373,7 +1417,7 @@ class Kubernetes(AbstractDCS):
|
||||
:param version: last known `resource_version` for conditional update of the object
|
||||
:returns: `True` if "delete" was successful
|
||||
"""
|
||||
return self.write_sync_state(None, None, version=version) is not None
|
||||
return self.write_sync_state(None, None, None, version=version) is not None
|
||||
|
||||
def watch(self, leader_version: Optional[str], timeout: float) -> bool:
|
||||
if self.__do_not_watch:
|
||||
|
||||
+25
-11
@@ -5,17 +5,19 @@ import threading
|
||||
import time
|
||||
|
||||
from collections import defaultdict
|
||||
from pysyncobj import SyncObj, SyncObjConf, replicated, FAIL_REASON
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Set, TYPE_CHECKING, Union
|
||||
|
||||
from pysyncobj import FAIL_REASON, replicated, SyncObj, SyncObjConf
|
||||
from pysyncobj.dns_resolver import globalDnsResolver
|
||||
from pysyncobj.node import TCPNode
|
||||
from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.transport import CONNECTION_STATE, TCPTransport
|
||||
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, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import validate_directory
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
@@ -253,7 +255,7 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
self.__limb.pop(key)
|
||||
self._expire(key, value, callback=callback)
|
||||
|
||||
def get(self, key: str, recursive: bool = False) -> Union[None, Dict[str, Any], Dict[str, Dict[str, Any]]]:
|
||||
def get(self, key: str, recursive: bool = False) -> Optional[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)}
|
||||
@@ -285,8 +287,8 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
class Raft(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Raft, self).__init__(config)
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Raft, self).__init__(config, mpp)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
|
||||
ready_event = threading.Event()
|
||||
@@ -375,19 +377,31 @@ class Raft(AbstractDCS):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
if not response:
|
||||
return Cluster.empty()
|
||||
nodes = {key[len(path):]: value for key, value in response.items()}
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
for key, value in (response or {}).items():
|
||||
key = key[len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = value
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
|
||||
|
||||
+29
-13
@@ -4,18 +4,20 @@ import select
|
||||
import socket
|
||||
import time
|
||||
|
||||
from kazoo.client import KazooClient, KazooState, KazooRetry
|
||||
from kazoo.exceptions import ConnectionClosedError, NoNodeError, NodeExistsError, SessionExpiredError
|
||||
from typing import Any, Callable, cast, Dict, List, Optional, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
from kazoo.client import KazooClient, KazooRetry, KazooState
|
||||
from kazoo.exceptions import ConnectionClosedError, NodeExistsError, NoNodeError, SessionExpiredError
|
||||
from kazoo.handlers.threading import AsyncResult, SequentialThreadingHandler
|
||||
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
|
||||
from kazoo.retry import RetryFailedError
|
||||
from kazoo.security import ACL, make_acl
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
@@ -87,8 +89,8 @@ class PatroniKazooClient(KazooClient):
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(ZooKeeper, self).__init__(config)
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(ZooKeeper, self).__init__(config, mpp)
|
||||
|
||||
hosts: Union[str, List[str]] = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
@@ -115,7 +117,8 @@ class ZooKeeper(AbstractDCS):
|
||||
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
|
||||
deadline=config['retry_timeout'], sleep_func=time.sleep),
|
||||
auth_data=list(config.get('auth_data', {}).items()), **kwargs)
|
||||
|
||||
self.__last_member_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -177,7 +180,8 @@ class ZooKeeper(AbstractDCS):
|
||||
return int(self._client._session_timeout / 1000.0)
|
||||
|
||||
def set_retry_timeout(self, retry_timeout: int) -> None:
|
||||
retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry
|
||||
old_kazoo = isinstance(self._client.retry, KazooRetry) # pyright: ignore [reportUnnecessaryIsInstance]
|
||||
retry = cast(KazooRetry, self._client.retry) if old_kazoo else self._client._retry
|
||||
retry.deadline = retry_timeout
|
||||
|
||||
def get_node(
|
||||
@@ -213,7 +217,13 @@ class ZooKeeper(AbstractDCS):
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
nodes = set(self.get_children(path))
|
||||
|
||||
# get initialize flag
|
||||
@@ -257,11 +267,17 @@ class ZooKeeper(AbstractDCS):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
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 + '/')
|
||||
if self._mpp.group_re.match(node):
|
||||
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
|
||||
return ret
|
||||
|
||||
def _load_cluster(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Helper functions to search for implementations of specific abstract interface in a package."""
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type, TYPE_CHECKING, TypeVar, Union
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def iter_modules(package: str) -> List[str]:
|
||||
"""Get names of modules from *package*, depending on execution environment.
|
||||
|
||||
.. note::
|
||||
If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because
|
||||
:class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to
|
||||
find all potential modules by iterating through ``toc``, which contains list of all "frozen" resources.
|
||||
|
||||
:param package: a package name to search modules in, e.g. ``patroni.dcs``.
|
||||
|
||||
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
|
||||
"""
|
||||
module_prefix = package + '.'
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
toc: Set[str] = set()
|
||||
# dirname may contain a few dots, which causes pkgutil.iter_importers()
|
||||
# to misinterpret the path as a package name. This can be avoided
|
||||
# altogether by not passing a path at all, because PyInstaller's
|
||||
# FrozenImporter is a singleton and registered as top-level finder.
|
||||
for importer in pkgutil.iter_importers():
|
||||
if hasattr(importer, 'toc'):
|
||||
toc |= getattr(importer, 'toc')
|
||||
# If it found the pyinstaller toc then use it, otherwise fall through to default
|
||||
# behavior which works in pyinstaller >= 4.4
|
||||
if len(toc) > 0:
|
||||
dots = module_prefix.count('.') # search for modules only on the same level
|
||||
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == dots]
|
||||
|
||||
# here we are making an assumption that the package which is calling this function is already imported
|
||||
pkg_file = sys.modules[package].__file__
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(pkg_file, str)
|
||||
return [name for _, name, is_pkg in pkgutil.iter_modules([os.path.dirname(pkg_file)], module_prefix) if not is_pkg]
|
||||
|
||||
|
||||
ClassType = TypeVar("ClassType")
|
||||
|
||||
|
||||
def find_class_in_module(module: ModuleType, cls_type: Type[ClassType]) -> Optional[Type[ClassType]]:
|
||||
"""Try to find the implementation of *cls_type* class interface in *module* matching the *module* name.
|
||||
|
||||
:param module: imported module.
|
||||
:param cls_type: a class type we are looking for.
|
||||
|
||||
:returns: class with a name matching the name of *module* that implements *cls_type* or ``None`` if not found.
|
||||
"""
|
||||
module_name = module.__name__.rpartition('.')[2]
|
||||
return next(
|
||||
(obj for obj_name, obj in module.__dict__.items()
|
||||
if (obj_name.lower() == module_name
|
||||
and inspect.isclass(obj) and issubclass(obj, cls_type))),
|
||||
None)
|
||||
|
||||
|
||||
def iter_classes(
|
||||
package: str, cls_type: Type[ClassType],
|
||||
config: Optional[Union['Config', Dict[str, Any]]] = None
|
||||
) -> Iterator[Tuple[str, Type[ClassType]]]:
|
||||
"""Attempt to import modules and find implementations of *cls_type* that are present in the given configuration.
|
||||
|
||||
.. note::
|
||||
If a module successfully imports we can assume that all its requirements are installed.
|
||||
|
||||
:param package: a package name to search modules in, e.g. ``patroni.dcs``.
|
||||
:param cls_type: a class type we are looking for.
|
||||
:param config: configuration information with possible module names as keys. If given, only attempt to import
|
||||
modules defined in the configuration. Else, if ``None``, attempt to import any supported module.
|
||||
|
||||
:yields: a tuple containing the module ``name`` and the imported class object.
|
||||
"""
|
||||
for mod_name in iter_modules(package):
|
||||
name = mod_name.rpartition('.')[2]
|
||||
if config is None or name in config:
|
||||
try:
|
||||
module = importlib.import_module(mod_name)
|
||||
module_cls = find_class_in_module(module, cls_type)
|
||||
if module_cls:
|
||||
yield name, module_cls
|
||||
except ImportError:
|
||||
logger.log(logging.DEBUG if config is not None else logging.INFO,
|
||||
'Failed to import %s', mod_name)
|
||||
+11
-3
@@ -39,19 +39,27 @@ class __FilePermissions:
|
||||
def __init__(self) -> None:
|
||||
"""Create a :class:`__FilePermissions` object and set default permissions."""
|
||||
self.__set_owner_permissions()
|
||||
self.__set_umask()
|
||||
self.__orig_umask = self.__set_umask()
|
||||
|
||||
def __set_umask(self) -> None:
|
||||
def __set_umask(self) -> int:
|
||||
"""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.
|
||||
|
||||
:returns: the previous value of the umask or ``0022`` if umask call failed.
|
||||
"""
|
||||
try:
|
||||
os.umask(self.__pg_mode_mask)
|
||||
return os.umask(self.__pg_mode_mask)
|
||||
except Exception as e:
|
||||
logger.error('Can not set umask to %03o: %r', self.__pg_mode_mask, e)
|
||||
return 0o22
|
||||
|
||||
@property
|
||||
def orig_umask(self) -> int:
|
||||
"""Original umask value."""
|
||||
return self.__orig_umask
|
||||
|
||||
def __set_owner_permissions(self) -> None:
|
||||
"""Make directories/files accessible only by the owner."""
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Implements *global_config* facilities.
|
||||
|
||||
The :class:`GlobalConfig` object is instantiated on import and replaces
|
||||
``patroni.global_config`` module in :data:`sys.modules`, what allows to use
|
||||
its properties and methods like they were module variables and functions.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .collections import EMPTY_DICT
|
||||
from .utils import parse_bool, parse_int
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .dcs import Cluster
|
||||
|
||||
|
||||
def __getattr__(mod: types.ModuleType, name: str) -> Any:
|
||||
"""This function exists just to make pyright happy.
|
||||
|
||||
Without it pyright complains about access to unknown members of global_config module.
|
||||
"""
|
||||
return getattr(sys.modules[__name__], name) # pragma: no cover
|
||||
|
||||
|
||||
class GlobalConfig(types.ModuleType):
|
||||
"""A class that wraps global configuration and provides convenient methods to access/check values."""
|
||||
|
||||
__file__ = __file__ # just to make unittest and pytest happy
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize :class:`GlobalConfig` object."""
|
||||
super().__init__(__name__)
|
||||
self.__config = {}
|
||||
|
||||
@staticmethod
|
||||
def _cluster_has_valid_config(cluster: Optional['Cluster']) -> bool:
|
||||
"""Check if provided *cluster* object has a valid global configuration.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
|
||||
:returns: ``True`` if provided *cluster* object has a valid global configuration, otherwise ``False``.
|
||||
"""
|
||||
return bool(cluster and cluster.config and cluster.config.modify_version)
|
||||
|
||||
def update(self, cluster: Optional['Cluster'], default: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Update with the new global configuration from the :class:`Cluster` object view.
|
||||
|
||||
.. note::
|
||||
Update happens in-place and is executed only from the main heartbeat thread.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
:param default: default configuration, which will be used if there is no valid *cluster.config*.
|
||||
"""
|
||||
# Try to protect from the case when DCS was wiped out
|
||||
if self._cluster_has_valid_config(cluster):
|
||||
self.__config = cluster.config.data # pyright: ignore [reportOptionalMemberAccess]
|
||||
elif default:
|
||||
self.__config = default
|
||||
|
||||
def from_cluster(self, cluster: Optional['Cluster']) -> 'GlobalConfig':
|
||||
"""Return :class:`GlobalConfig` instance from the provided :class:`Cluster` object view.
|
||||
|
||||
.. note::
|
||||
If the provided *cluster* object doesn't have a valid global configuration we return
|
||||
the last known valid state of the :class:`GlobalConfig` object.
|
||||
|
||||
This method is used when we need to have the most up-to-date values in the global configuration,
|
||||
but we don't want to update the global object.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
|
||||
:returns: :class:`GlobalConfig` object.
|
||||
"""
|
||||
if not self._cluster_has_valid_config(cluster):
|
||||
return self
|
||||
|
||||
ret = GlobalConfig()
|
||||
ret.update(cluster)
|
||||
return ret
|
||||
|
||||
def get(self, name: str) -> Any:
|
||||
"""Gets global configuration value by *name*.
|
||||
|
||||
:param name: parameter name.
|
||||
|
||||
:returns: configuration value or ``None`` if it is missing.
|
||||
"""
|
||||
return self.__config.get(name)
|
||||
|
||||
def check_mode(self, mode: str) -> bool:
|
||||
"""Checks whether the certain parameter is enabled.
|
||||
|
||||
:param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and
|
||||
so on.
|
||||
|
||||
:returns: ``True`` if parameter *mode* is enabled in the global configuration.
|
||||
"""
|
||||
return bool(parse_bool(self.__config.get(mode)))
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
"""``True`` if cluster is in maintenance mode."""
|
||||
return self.check_mode('pause')
|
||||
|
||||
@property
|
||||
def is_quorum_commit_mode(self) -> bool:
|
||||
""":returns: ``True`` if quorum commit replication is requested"""
|
||||
return str(self.get('synchronous_mode')).lower() == 'quorum'
|
||||
|
||||
@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') is True or self.is_quorum_commit_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) -> 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\
|
||||
any(cast(Dict[str, Any], config).get(p) for p in ('host', 'port', 'restore_command'))
|
||||
|
||||
def get_int(self, name: str, default: int = 0, base_unit: Optional[str] = None) -> 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.
|
||||
:param base_unit: an optional base unit to convert value of *name* parameter to.
|
||||
Not used if the value does not contain a unit.
|
||||
|
||||
: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), base_unit)
|
||||
return default if ret is None else ret
|
||||
|
||||
@property
|
||||
def min_synchronous_nodes(self) -> int:
|
||||
"""The minimum number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not."""
|
||||
return 1 if self.is_synchronous_mode_strict else 0
|
||||
|
||||
@property
|
||||
def synchronous_node_count(self) -> int:
|
||||
"""Currently configured value of ``synchronous_node_count`` from the global configuration.
|
||||
|
||||
Assume ``1`` if it is not set or invalid.
|
||||
"""
|
||||
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
|
||||
|
||||
@property
|
||||
def maximum_lag_on_failover(self) -> int:
|
||||
"""Currently configured value of ``maximum_lag_on_failover`` from the global configuration.
|
||||
|
||||
Assume ``1048576`` if it is not set or invalid.
|
||||
"""
|
||||
return self.get_int('maximum_lag_on_failover', 1048576)
|
||||
|
||||
@property
|
||||
def maximum_lag_on_syncnode(self) -> int:
|
||||
"""Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration.
|
||||
|
||||
Assume ``-1`` if it is not set or invalid.
|
||||
"""
|
||||
return self.get_int('maximum_lag_on_syncnode', -1)
|
||||
|
||||
@property
|
||||
def primary_start_timeout(self) -> int:
|
||||
"""Currently configured value of ``primary_start_timeout`` from the global configuration.
|
||||
|
||||
Assume ``300`` if it is not set or invalid.
|
||||
|
||||
.. note::
|
||||
``master_start_timeout`` is still supported to keep backward compatibility.
|
||||
"""
|
||||
default = 300
|
||||
return self.get_int('primary_start_timeout', default)\
|
||||
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
|
||||
|
||||
@property
|
||||
def primary_stop_timeout(self) -> int:
|
||||
"""Currently configured value of ``primary_stop_timeout`` from the global configuration.
|
||||
|
||||
Assume ``0`` if it is not set or invalid.
|
||||
|
||||
.. note::
|
||||
``master_stop_timeout`` is still supported to keep backward compatibility.
|
||||
"""
|
||||
default = 0
|
||||
return self.get_int('primary_stop_timeout', default)\
|
||||
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
|
||||
|
||||
@property
|
||||
def ignore_slots_matchers(self) -> List[Dict[str, Any]]:
|
||||
"""Currently configured value of ``ignore_slots`` from the global configuration.
|
||||
|
||||
Assume an empty :class:`list` if not set.
|
||||
"""
|
||||
return self.get('ignore_slots') or []
|
||||
|
||||
@property
|
||||
def max_timelines_history(self) -> int:
|
||||
"""Currently configured value of ``max_timelines_history`` from the global configuration.
|
||||
|
||||
Assume ``0`` if not set or invalid.
|
||||
"""
|
||||
return self.get_int('max_timelines_history', 0)
|
||||
|
||||
@property
|
||||
def use_slots(self) -> bool:
|
||||
"""``True`` if cluster is configured to use replication slots."""
|
||||
return bool(parse_bool((self.get('postgresql') or EMPTY_DICT).get('use_slots', True)))
|
||||
|
||||
@property
|
||||
def permanent_slots(self) -> Dict[str, Any]:
|
||||
"""Dictionary of permanent slots information from the global configuration."""
|
||||
return deepcopy(self.get('permanent_replication_slots')
|
||||
or self.get('permanent_slots')
|
||||
or self.get('slots')
|
||||
or EMPTY_DICT.copy())
|
||||
|
||||
@property
|
||||
def member_slots_ttl(self) -> int:
|
||||
"""Currently configured value of ``member_slots_ttl`` from the global configuration converted to seconds.
|
||||
|
||||
Assume ``1800`` if it is not set or invalid.
|
||||
"""
|
||||
return self.get_int('member_slots_ttl', 1800, base_unit='s')
|
||||
|
||||
|
||||
sys.modules[__name__] = GlobalConfig()
|
||||
+582
-235
File diff suppressed because it is too large
Load Diff
+264
-33
@@ -8,16 +8,52 @@ import os
|
||||
import sys
|
||||
|
||||
from copy import deepcopy
|
||||
from io import TextIOWrapper
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from patroni.utils import deep_compare
|
||||
from queue import Queue, Full
|
||||
from queue import Full, Queue
|
||||
from threading import Lock, Thread
|
||||
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
|
||||
from .file_perm import pg_perm
|
||||
from .utils import deep_compare, parse_int
|
||||
|
||||
type_logformat = Union[List[Union[str, Dict[str, Any], Any]], str, Any]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PatroniFileHandler(RotatingFileHandler):
|
||||
"""Wrapper of :class:`RotatingFileHandler` to handle permissions of log files. """
|
||||
|
||||
def __init__(self, filename: str, mode: Optional[int]) -> None:
|
||||
"""Create a new :class:`PatroniFileHandler` instance.
|
||||
|
||||
:param filename: basename for log files.
|
||||
:param mode: permissions for log files.
|
||||
"""
|
||||
self.set_log_file_mode(mode)
|
||||
super(PatroniFileHandler, self).__init__(filename)
|
||||
|
||||
def set_log_file_mode(self, mode: Optional[int]) -> None:
|
||||
"""Set mode for Patroni log files.
|
||||
|
||||
:param mode: permissions for log files.
|
||||
|
||||
.. note::
|
||||
If *mode* is not specified, we calculate it from the `umask` value.
|
||||
"""
|
||||
self._log_file_mode = 0o666 & ~pg_perm.orig_umask if mode is None else mode
|
||||
|
||||
def _open(self) -> TextIOWrapper:
|
||||
"""Open a new log file and assign permissions.
|
||||
|
||||
:returns: the resulting stream.
|
||||
"""
|
||||
ret = super(PatroniFileHandler, self)._open()
|
||||
os.chmod(self.baseFilename, self._log_file_mode)
|
||||
return ret
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -59,6 +95,15 @@ def error_exception(self: logging.Logger, msg: object, *args: Any, **kwargs: Any
|
||||
self.error(msg, *args, exc_info=exc_info, **kwargs)
|
||||
|
||||
|
||||
def _type(value: Any) -> str:
|
||||
"""Get type of the *value*.
|
||||
|
||||
:param value: any arbitrary value.
|
||||
:returns: a string with a type name.
|
||||
"""
|
||||
return value.__class__.__name__
|
||||
|
||||
|
||||
class QueueHandler(logging.Handler):
|
||||
"""Queue-based logging handler.
|
||||
|
||||
@@ -157,6 +202,7 @@ class PatroniLogger(Thread):
|
||||
.. seealso::
|
||||
:class:`QueueHandler`: object used for enqueueing messages in-memory.
|
||||
|
||||
:cvar DEFAULT_TYPE: default type of log format (``plain``).
|
||||
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
|
||||
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
|
||||
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
|
||||
@@ -169,6 +215,7 @@ class PatroniLogger(Thread):
|
||||
:ivar log_handler_lock: lock used to modify ``log_handler``.
|
||||
"""
|
||||
|
||||
DEFAULT_TYPE = 'plain'
|
||||
DEFAULT_LEVEL = 'INFO'
|
||||
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
|
||||
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
|
||||
@@ -202,28 +249,195 @@ class PatroniLogger(Thread):
|
||||
self._proxy_handler = ProxyHandler(self)
|
||||
self._root_logger.addHandler(self._proxy_handler)
|
||||
|
||||
def update_loggers(self) -> None:
|
||||
"""Configure loggers' log level as defined in ``log.loggers`` section of Patroni configuration.
|
||||
def update_loggers(self, config: Dict[str, Any]) -> None:
|
||||
"""Configure custom loggers' log levels.
|
||||
|
||||
.. note::
|
||||
It creates logger objects that are not defined yet in the log manager.
|
||||
|
||||
:param config: :class:`dict` object with custom loggers configuration, is set either from:
|
||||
|
||||
* ``log.loggers`` section of Patroni configuration; or
|
||||
|
||||
* from the method that is trying to make sure that the node name
|
||||
isn't duplicated (to silence annoying ``urllib3`` WARNING's).
|
||||
|
||||
:Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
update_loggers({'urllib3.connectionpool': 'WARNING'})
|
||||
"""
|
||||
loggers = deepcopy((self._config or {}).get('loggers') or {})
|
||||
loggers = deepcopy(config)
|
||||
for name, logger in self._root_logger.manager.loggerDict.items():
|
||||
# ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only
|
||||
# in the ones that were defined
|
||||
if not isinstance(logger, logging.PlaceHolder):
|
||||
# if this logger is present in ``log.loggers`` Patroni configuration, use the configured level,
|
||||
# otherwise use ``logging.NOTSET``, which means it will inherit the level from any parent node up to
|
||||
# the root for which log level is defined.
|
||||
# if this logger is present in *config*, use the configured level, otherwise
|
||||
# use ``logging.NOTSET``, which means it will inherit the level
|
||||
# from any parent node up to the root for which log level is defined.
|
||||
level = loggers.pop(name, logging.NOTSET)
|
||||
logger.setLevel(level)
|
||||
|
||||
# define loggers that do not exist yet and set level as configured in ``log.loggers`` section of configuration.
|
||||
# define loggers that do not exist yet and set level as configured in the *config*
|
||||
for name, level in loggers.items():
|
||||
logger = self._root_logger.manager.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
def _is_config_changed(self, config: Dict[str, Any]) -> bool:
|
||||
"""Checks if the given config is different from the current one.
|
||||
|
||||
:param config: ``log`` section from Patroni configuration.
|
||||
|
||||
:returns: ``True`` if the config is changed, ``False`` otherwise.
|
||||
"""
|
||||
old_config = self._config or {}
|
||||
|
||||
oldlogtype = old_config.get('type', PatroniLogger.DEFAULT_TYPE)
|
||||
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
|
||||
|
||||
oldlogformat: type_logformat = old_config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
|
||||
olddateformat = old_config.get('dateformat') or None
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
|
||||
old_static_fields = old_config.get('static_fields', {})
|
||||
static_fields = config.get('static_fields', {})
|
||||
|
||||
old_log_config = {
|
||||
'type': oldlogtype,
|
||||
'format': oldlogformat,
|
||||
'dateformat': olddateformat,
|
||||
'static_fields': old_static_fields
|
||||
}
|
||||
|
||||
log_config = {
|
||||
'type': logtype,
|
||||
'format': logformat,
|
||||
'dateformat': dateformat,
|
||||
'static_fields': static_fields
|
||||
}
|
||||
|
||||
return not deep_compare(old_log_config, log_config)
|
||||
|
||||
def _get_plain_formatter(self, logformat: type_logformat, dateformat: Optional[str]) -> logging.Formatter:
|
||||
"""Returns a logging formatter with the specified format and date format.
|
||||
|
||||
.. note::
|
||||
If the log format isn't a string, prints a warning message and uses the default log format instead.
|
||||
|
||||
:param logformat: The format of the log messages.
|
||||
:param dateformat: The format of the timestamp in the log messages.
|
||||
|
||||
:returns: A logging formatter object that can be used to format log records.
|
||||
"""
|
||||
|
||||
if not isinstance(logformat, str):
|
||||
_LOGGER.warning('Expected log format to be a string when log type is plain, but got "%s"', _type(logformat))
|
||||
logformat = PatroniLogger.DEFAULT_FORMAT
|
||||
|
||||
return logging.Formatter(logformat, dateformat)
|
||||
|
||||
def _get_json_formatter(self, logformat: type_logformat, dateformat: Optional[str],
|
||||
static_fields: Dict[str, Any]) -> logging.Formatter:
|
||||
"""Returns a logging formatter that outputs JSON formatted messages.
|
||||
|
||||
.. note::
|
||||
If :mod:`pythonjsonlogger` library is not installed, prints an error message and returns
|
||||
a plain log formatter instead.
|
||||
|
||||
:param logformat: Specifies the log fields and their key names in the JSON log message.
|
||||
:param dateformat: The format of the timestamp in the log messages.
|
||||
:param static_fields: A dictionary of static fields that are added to every log message.
|
||||
|
||||
:returns: A logging formatter object that can be used to format log records as JSON strings.
|
||||
"""
|
||||
|
||||
if isinstance(logformat, str):
|
||||
jsonformat = logformat
|
||||
rename_fields = {}
|
||||
elif isinstance(logformat, list):
|
||||
logformat = cast(List[Any], logformat)
|
||||
log_fields: List[str] = []
|
||||
rename_fields: Dict[str, str] = {}
|
||||
|
||||
for field in logformat:
|
||||
if isinstance(field, str):
|
||||
log_fields.append(field)
|
||||
elif isinstance(field, dict):
|
||||
field = cast(Dict[str, Any], field)
|
||||
for original_field, renamed_field in field.items():
|
||||
if isinstance(renamed_field, str):
|
||||
log_fields.append(original_field)
|
||||
rename_fields[original_field] = renamed_field
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
'Expected renamed log field to be a string, but got "%s"',
|
||||
_type(renamed_field)
|
||||
)
|
||||
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
'Expected each item of log format to be a string or dictionary, but got "%s"',
|
||||
_type(field)
|
||||
)
|
||||
|
||||
if len(log_fields) > 0:
|
||||
jsonformat = ' '.join([f'%({field})s' for field in log_fields])
|
||||
else:
|
||||
jsonformat = PatroniLogger.DEFAULT_FORMAT
|
||||
else:
|
||||
jsonformat = PatroniLogger.DEFAULT_FORMAT
|
||||
rename_fields = {}
|
||||
_LOGGER.warning('Expected log format to be a string or a list, but got "%s"', _type(logformat))
|
||||
|
||||
try:
|
||||
try:
|
||||
from pythonjsonlogger import json as jsonlogger # pyright: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
from pythonjsonlogger import jsonlogger
|
||||
if hasattr(jsonlogger, 'RESERVED_ATTRS') \
|
||||
and 'taskName' not in jsonlogger.RESERVED_ATTRS: # pyright: ignore [reportPrivateImportUsage]
|
||||
# compatibility with python 3.12, that added a new attribute to LogRecord
|
||||
jsonlogger.RESERVED_ATTRS += ('taskName',) # pyright: ignore
|
||||
|
||||
return jsonlogger.JsonFormatter( # pyright: ignore [reportPrivateImportUsage]
|
||||
jsonformat,
|
||||
dateformat,
|
||||
rename_fields=rename_fields,
|
||||
static_fields=static_fields
|
||||
)
|
||||
except ImportError as e:
|
||||
_LOGGER.error('Failed to import "python-json-logger" library: %r. Falling back to the plain logger', e)
|
||||
except Exception as e:
|
||||
_LOGGER.error('Failed to initialize JsonFormatter: %r. Falling back to the plain logger', e)
|
||||
|
||||
return self._get_plain_formatter(jsonformat, dateformat)
|
||||
|
||||
def _get_formatter(self, config: Dict[str, Any]) -> logging.Formatter:
|
||||
"""Returns a logging formatter based on the type of logger in the given configuration.
|
||||
|
||||
:param config: ``log`` section from Patroni configuration.
|
||||
|
||||
:returns: A :class:`logging.Formatter` object that can be used to format log records.
|
||||
"""
|
||||
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
|
||||
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
static_fields = config.get('static_fields', {})
|
||||
|
||||
if dateformat is not None and not isinstance(dateformat, str):
|
||||
_LOGGER.warning('Expected log dateformat to be a string, but got "%s"', _type(dateformat))
|
||||
dateformat = None
|
||||
|
||||
if logtype == 'json':
|
||||
formatter = self._get_json_formatter(logformat, dateformat, static_fields)
|
||||
else:
|
||||
formatter = self._get_plain_formatter(logformat, dateformat)
|
||||
|
||||
return formatter
|
||||
|
||||
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Apply log related configuration.
|
||||
|
||||
@@ -244,44 +458,42 @@ class PatroniLogger(Thread):
|
||||
# show stack traces as ``ERROR`` log messages
|
||||
logging.Logger.exception = error_exception
|
||||
|
||||
new_handler = None
|
||||
handler = self.log_handler
|
||||
|
||||
if 'dir' in config:
|
||||
if not isinstance(self.log_handler, RotatingFileHandler):
|
||||
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
|
||||
handler = new_handler or self.log_handler
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(handler, RotatingFileHandler)
|
||||
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
|
||||
mode = parse_int(config.get('mode'))
|
||||
if not isinstance(handler, PatroniFileHandler):
|
||||
handler = PatroniFileHandler(os.path.join(config['dir'], __name__), mode)
|
||||
handler.set_log_file_mode(mode)
|
||||
max_file_size = int(config.get('file_size', 25000000))
|
||||
handler.maxBytes = max_file_size # pyright: ignore [reportAttributeAccessIssue]
|
||||
handler.backupCount = int(config.get('file_num', 4))
|
||||
else:
|
||||
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
|
||||
new_handler = logging.StreamHandler()
|
||||
handler = new_handler or self.log_handler
|
||||
# we can't use `if not isinstance(handler, logging.StreamHandler)` below,
|
||||
# because RotatingFileHandler and PatroniFileHandler are children of StreamHandler!!!
|
||||
elif handler is None or isinstance(handler, PatroniFileHandler):
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
is_new_handler = handler != self.log_handler
|
||||
|
||||
olddateformat = (self._config or {}).get('dateformat') or None
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
if (self._is_config_changed(config) or is_new_handler) and handler:
|
||||
formatter = self._get_formatter(config)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
|
||||
handler.setFormatter(logging.Formatter(logformat, dateformat))
|
||||
|
||||
if new_handler:
|
||||
if is_new_handler:
|
||||
with self.log_handler_lock:
|
||||
if self.log_handler:
|
||||
self._old_handlers.append(self.log_handler)
|
||||
self.log_handler = new_handler
|
||||
self.log_handler = handler
|
||||
|
||||
self._config = config.copy()
|
||||
self.update_loggers()
|
||||
self.update_loggers(config.get('loggers') or {})
|
||||
|
||||
def _close_old_handlers(self) -> None:
|
||||
"""Close old log handlers.
|
||||
|
||||
.. 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
|
||||
e.g. if we are switching from :class:`PatroniFileHandler` to
|
||||
class:`~logging.StreamHandler` and vice-versa.
|
||||
"""
|
||||
while True:
|
||||
@@ -305,6 +517,7 @@ class PatroniLogger(Thread):
|
||||
self._root_logger.removeHandler(self._proxy_handler)
|
||||
|
||||
prev_record = None
|
||||
prev_hb_msg = ''
|
||||
|
||||
while True:
|
||||
self._close_old_handlers()
|
||||
@@ -323,8 +536,16 @@ class PatroniLogger(Thread):
|
||||
prev_record, record = record, None
|
||||
else:
|
||||
if prev_record and prev_record.thread == record.thread:
|
||||
if not (record.msg.startswith('no action. ') or record.msg.startswith('PAUSE: no action')):
|
||||
if self._is_heartbeat_msg(record):
|
||||
config = self._config or {}
|
||||
deduplicate_heartbeat_logs = config.get('deduplicate_heartbeat_logs', False)
|
||||
if record.msg == prev_hb_msg and deduplicate_heartbeat_logs:
|
||||
record = None
|
||||
else:
|
||||
prev_hb_msg = record.msg
|
||||
else:
|
||||
self.log_handler.handle(prev_record)
|
||||
prev_hb_msg = None
|
||||
prev_record = None
|
||||
|
||||
if record:
|
||||
@@ -332,6 +553,16 @@ class PatroniLogger(Thread):
|
||||
|
||||
self._queue_handler.queue.task_done()
|
||||
|
||||
@staticmethod
|
||||
def _is_heartbeat_msg(record: logging.LogRecord) -> bool:
|
||||
"""Checks if the given record contains a heartbeat message.
|
||||
|
||||
:param record: the record to check.
|
||||
|
||||
:returns: ``True`` if the record contains a heartbeat message, ``False`` otherwise.
|
||||
"""
|
||||
return record.msg.startswith('no action. ') or record.msg.startswith('PAUSE: no action')
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shut down the logger thread."""
|
||||
try:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user