mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment**
|
||||
- Patroni version:
|
||||
- PostgreSQL version:
|
||||
- DCS (and its version):
|
||||
|
||||
**Patroni configuration file**
|
||||
```
|
||||
Please copy&paste your Patroni configuration file here
|
||||
```
|
||||
|
||||
**patronictl show-config**
|
||||
```
|
||||
Please copy&paste the output of "patronictl show-config" command here
|
||||
```
|
||||
|
||||
**Have you checked Patroni logs?**
|
||||
Please provide a snippet of Patroni log files here
|
||||
|
||||
**Have you checked PostgreSQL logs?**
|
||||
Please provide a snippet here
|
||||
|
||||
**Have you tried to use GitHub issue search?**
|
||||
Maybe there is already a similar issue solved.
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,186 @@
|
||||
import inspect
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
|
||||
def install_requirements(what):
|
||||
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)))
|
||||
try:
|
||||
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 += ['psycopg2-binary', 'coverage']
|
||||
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)
|
||||
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
r = subprocess.call([sys.executable, '-m', 'pip', 'install'] + requirements)
|
||||
s = subprocess.call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'setuptools'])
|
||||
return s | r
|
||||
|
||||
|
||||
def install_packages(what):
|
||||
from mapping import versions
|
||||
|
||||
packages = {
|
||||
'zookeeper': ['zookeeper', 'zookeeper-bin', 'zookeeperd'],
|
||||
'consul': ['consul'],
|
||||
}
|
||||
packages['exhibitor'] = packages['zookeeper']
|
||||
packages = packages.get(what, [])
|
||||
ver = versions.get(what)
|
||||
subprocess.call(['sudo', 'sed', '-i', 's/pgdg main.*$/pgdg main {0}/'.format(ver),
|
||||
'/etc/apt/sources.list.d/pgdg.list'])
|
||||
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev', 'wget'] + packages)
|
||||
|
||||
|
||||
def get_file(url, name):
|
||||
try:
|
||||
from urllib.request import urlretrieve
|
||||
except ImportError:
|
||||
from urllib import urlretrieve
|
||||
|
||||
print('Downloading ' + url)
|
||||
urlretrieve(url, name)
|
||||
|
||||
|
||||
def untar(archive, name):
|
||||
with tarfile.open(archive) as tar:
|
||||
f = tar.extractfile(name)
|
||||
dest = os.path.basename(name)
|
||||
with open(dest, 'wb') as d:
|
||||
shutil.copyfileobj(f, d)
|
||||
return dest
|
||||
|
||||
|
||||
def unzip(archive, name):
|
||||
with zipfile.ZipFile(archive, 'r') as z:
|
||||
name = z.extract(name)
|
||||
dest = os.path.basename(name)
|
||||
shutil.move(name, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def unzip_all(archive):
|
||||
print('Extracting ' + archive)
|
||||
with zipfile.ZipFile(archive, 'r') as z:
|
||||
z.extractall()
|
||||
|
||||
|
||||
def chmod_755(name):
|
||||
os.chmod(name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |
|
||||
stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
|
||||
|
||||
def unpack(archive, name):
|
||||
print('Extracting {0} from {1}'.format(name, archive))
|
||||
func = unzip if archive.endswith('.zip') else untar
|
||||
name = func(archive, name)
|
||||
chmod_755(name)
|
||||
return name
|
||||
|
||||
|
||||
def install_etcd():
|
||||
version = os.environ.get('ETCDVERSION', '3.3.13')
|
||||
platform = {'linux2': 'linux', 'win32': 'windows', 'cygwin': 'windows'}.get(sys.platform, sys.platform)
|
||||
dirname = 'etcd-v{0}-{1}-amd64'.format(version, platform)
|
||||
ext = 'tar.gz' if platform == 'linux' else 'zip'
|
||||
name = '{0}.{1}'.format(dirname, ext)
|
||||
url = 'https://github.com/etcd-io/etcd/releases/download/v{0}/{1}'.format(version, name)
|
||||
get_file(url, name)
|
||||
ext = '.exe' if platform == 'windows' else ''
|
||||
return int(unpack(name, '{0}/etcd{1}'.format(dirname, ext)) is None)
|
||||
|
||||
|
||||
def install_postgres():
|
||||
version = os.environ.get('PGVERSION', '12.1-1')
|
||||
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
|
||||
name = 'postgresql-{0}-{1}-binaries.zip'.format(version, platform)
|
||||
get_file('http://get.enterprisedb.com/postgresql/' + name, name)
|
||||
unzip_all(name)
|
||||
bin_dir = os.path.join('pgsql', 'bin')
|
||||
for f in os.listdir(bin_dir):
|
||||
chmod_755(os.path.join(bin_dir, f))
|
||||
subprocess.call(['pgsql/bin/postgres', '-V'])
|
||||
return 0
|
||||
|
||||
|
||||
def setup_kubernetes():
|
||||
get_file('https://storage.googleapis.com/minikube/k8sReleases/v1.7.0/localkube-linux-amd64', 'localkube')
|
||||
chmod_755('localkube')
|
||||
|
||||
devnull = open(os.devnull, 'w')
|
||||
subprocess.Popen(['sudo', 'nohup', './localkube', '--logtostderr=true', '--enable-dns=false'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
for _ in range(0, 120):
|
||||
if subprocess.call(['wget', '-qO', '-', 'http://127.0.0.1:8080/'], stdout=devnull, stderr=devnull) == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
print('localkube did not start')
|
||||
return 1
|
||||
|
||||
subprocess.call('sudo chmod 644 /var/lib/localkube/certs/*', shell=True)
|
||||
print('Set up .kube/config')
|
||||
kube = os.path.join(os.path.expanduser('~'), '.kube')
|
||||
os.makedirs(kube)
|
||||
with open(os.path.join(kube, 'config'), 'w') as f:
|
||||
f.write("""apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority: /var/lib/localkube/certs/ca.crt
|
||||
server: https://127.0.0.1:8443
|
||||
name: local
|
||||
contexts:
|
||||
- context:
|
||||
cluster: local
|
||||
user: myself
|
||||
name: local
|
||||
current-context: local
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: myself
|
||||
user:
|
||||
client-certificate: /var/lib/localkube/certs/apiserver.crt
|
||||
client-key: /var/lib/localkube/certs/apiserver.key
|
||||
""")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
what = os.environ.get('DCS', sys.argv[1] if len(sys.argv) > 1 else 'all')
|
||||
|
||||
if what != 'all':
|
||||
if sys.platform.startswith('linux'):
|
||||
r = install_packages(what)
|
||||
if r == 0 and what == 'kubernetes':
|
||||
r = setup_kubernetes()
|
||||
else:
|
||||
r = install_postgres()
|
||||
|
||||
if r == 0 and what.startswith('etcd'):
|
||||
r = install_etcd()
|
||||
|
||||
if r != 0:
|
||||
return r
|
||||
|
||||
return install_requirements(what)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '14'}
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def main():
|
||||
what = os.environ.get('DCS', sys.argv[1] if len(sys.argv) > 1 else 'all')
|
||||
|
||||
if what == 'all':
|
||||
flake8 = subprocess.call([sys.executable, 'setup.py', 'flake8'])
|
||||
test = subprocess.call([sys.executable, 'setup.py', 'test'])
|
||||
version = '.'.join(map(str, sys.version_info[:2]))
|
||||
shutil.move('.coverage', os.path.join(tempfile.gettempdir(), '.coverage.' + version))
|
||||
return flake8 | test
|
||||
elif what == 'combine':
|
||||
tmp = tempfile.gettempdir()
|
||||
for name in os.listdir(tmp):
|
||||
if name.startswith('.coverage.'):
|
||||
shutil.move(os.path.join(tmp, name), name)
|
||||
return subprocess.call([sys.executable, '-m', 'coverage', 'combine'])
|
||||
|
||||
env = os.environ.copy()
|
||||
if sys.platform.startswith('linux'):
|
||||
from mapping import versions
|
||||
|
||||
version = versions.get(what)
|
||||
path = '/usr/lib/postgresql/{0}/bin:.'.format(version)
|
||||
unbuffer = ['timeout', '600', 'unbuffer']
|
||||
args = ['--tags=-skip'] if what == 'etcd' else []
|
||||
else:
|
||||
path = os.path.abspath(os.path.join('pgsql', 'bin'))
|
||||
if sys.platform == 'darwin':
|
||||
path += ':.'
|
||||
args = unbuffer = []
|
||||
env['PATH'] = path + os.pathsep + env['PATH']
|
||||
env['DCS'] = what
|
||||
|
||||
ret = subprocess.call(unbuffer + [sys.executable, '-m', 'behave'] + args, env=env)
|
||||
|
||||
if ret != 0:
|
||||
if subprocess.call('grep . features/output/*_failed/*postgres?.*', shell=True) != 0:
|
||||
subprocess.call('grep . features/output/*/*postgres?.*', shell=True)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,177 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v.*
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu, windows, macos]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 2.7
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 2.7
|
||||
if: matrix.os != 'windows'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
if: matrix.os != 'windows'
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
if: matrix.os != 'windows'
|
||||
|
||||
- name: Set up Python 3.5
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.5
|
||||
- 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.6
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.6
|
||||
- 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.7
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
- 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.8
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
- 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.9
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Combine coverage
|
||||
run: python .github/workflows/run_tests.py combine
|
||||
|
||||
- name: Install coveralls
|
||||
run: python -m pip install coveralls
|
||||
|
||||
- name: Upload Coverage
|
||||
env:
|
||||
COVERALLS_FLAG_NAME: unit-${{ matrix.os }}
|
||||
COVERALLS_PARALLEL: 'true'
|
||||
GITHUB_TOKEN: ${{ secrets.github_token }}
|
||||
run: python -m coveralls --service=github
|
||||
|
||||
- name: Run codacy-coverage-reporter
|
||||
uses: codacy/codacy-coverage-reporter-action@master
|
||||
env:
|
||||
SECRETS_AVAILABLE: ${{ secrets.CODACY_PROJECT_TOKEN != '' }}
|
||||
with:
|
||||
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
coverage-reports: coverage.xml
|
||||
if: ${{ matrix.os == 'ubuntu' && env.SECRETS_AVAILABLE == 'true' }}
|
||||
|
||||
behave:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
env:
|
||||
DCS: ${{ matrix.dcs }}
|
||||
ETCDVERSION: 3.3.13
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu]
|
||||
python-version: [2.7, 3.5, 3.8]
|
||||
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Add postgresql apt repo
|
||||
run: sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run behave tests
|
||||
run: python .github/workflows/run_tests.py
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install coveralls
|
||||
run: python -m pip install coveralls
|
||||
- name: Upload Coverage
|
||||
env:
|
||||
COVERALLS_FLAG_NAME: behave-${{ matrix.os }}-${{ matrix.dcs }}-${{ matrix.python-version }}
|
||||
COVERALLS_PARALLEL: 'true'
|
||||
GITHUB_TOKEN: ${{ secrets.github_token }}
|
||||
run: python -m coveralls --service=github
|
||||
|
||||
behavem:
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
env:
|
||||
DCS: ${{ matrix.dcs }}
|
||||
ETCDVERSION: 3.3.13
|
||||
PGVERSION: 12.1-1 # for windows and macos
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos] #, windows]
|
||||
python-version: [3.7]
|
||||
dcs: [etcd, etcd3, raft]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: python .github/workflows/install_deps.py
|
||||
- name: Run behave tests
|
||||
run: python .github/workflows/run_tests.py
|
||||
- name: Install coveralls
|
||||
run: python -m pip install coveralls
|
||||
- name: Upload Coverage
|
||||
env:
|
||||
COVERALLS_FLAG_NAME: behave-${{ matrix.os }}-${{ matrix.dcs }}-${{ matrix.python-version }}
|
||||
COVERALLS_PARALLEL: 'true'
|
||||
GITHUB_TOKEN: ${{ secrets.github_token }}
|
||||
run: python -m coveralls --service=github
|
||||
|
||||
coveralls-finish:
|
||||
name: Finalize coveralls.io
|
||||
needs: [unit, behave, behavem]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-python@v2
|
||||
- run: python -m pip install coveralls
|
||||
- run: python -m coveralls --service=github --finish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.github_token }}
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
sudo: true
|
||||
dist: trusty
|
||||
language: python
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- expect-dev # for unbuffer
|
||||
env:
|
||||
global:
|
||||
- ETCDVERSION=3.3.13 ZKVERSION=3.4.14 CONSULVERSION=0.7.4
|
||||
- PYVERSIONS="2.7 3.6"
|
||||
- BOTO_CONFIG=/doesnotexist
|
||||
matrix:
|
||||
include:
|
||||
- python: "3.5"
|
||||
env: PYVERSIONS="2.7 3.5 3.6" TEST_SUITE="python setup.py"
|
||||
- python: "3.6"
|
||||
env: DCS="etcd" TEST_SUITE="behave"
|
||||
- python: "3.6"
|
||||
env: DCS="etcd3" TEST_SUITE="behave"
|
||||
- python: "3.6"
|
||||
env: DCS="exhibitor" TEST_SUITE="behave"
|
||||
- python: "3.6"
|
||||
env: DCS="consul" TEST_SUITE="behave"
|
||||
- python: "3.6"
|
||||
env: DCS="raft" TEST_SUITE="behave"
|
||||
- python: "3.6"
|
||||
env: DCS="kubernetes" TEST_SUITE="behave"
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- /^v\d+\.\d+(\.\d+)?$/
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/mycache
|
||||
before_cache:
|
||||
- |
|
||||
rm -fr $HOME/mycache/python*
|
||||
for pv in $PYVERSIONS; do
|
||||
fpv=$(basename $(readlink $HOME/virtualenv/python${pv}))
|
||||
mv $HOME/virtualenv/${fpv} $HOME/mycache/${fpv}
|
||||
done
|
||||
install:
|
||||
- |
|
||||
set -e
|
||||
|
||||
if [[ $TEST_SUITE == "behave" ]]; then
|
||||
function get_consul() {
|
||||
CC=~/mycache/consul_${CONSULVERSION}
|
||||
if [[ ! -x $CC ]]; then
|
||||
curl -L https://releases.hashicorp.com/consul/${CONSULVERSION}/consul_${CONSULVERSION}_linux_amd64.zip \
|
||||
| gunzip > $CC
|
||||
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
|
||||
chmod +x $CC
|
||||
fi
|
||||
ln -s $CC consul
|
||||
}
|
||||
|
||||
function get_etcd() {
|
||||
EC=~/mycache/etcd_${ETCDVERSION}
|
||||
if [[ ! -x $EC ]]; then
|
||||
rm -rf ~/mycache/etcd_*
|
||||
curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
|
||||
| tar xz -C . --strip=1 --wildcards --no-anchored etcd
|
||||
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
|
||||
mv etcd $EC
|
||||
fi
|
||||
ln -s $EC etcd
|
||||
}
|
||||
|
||||
function get_etcd3() {
|
||||
get_etcd
|
||||
}
|
||||
|
||||
function get_kubernetes() {
|
||||
wget -O localkube "https://storage.googleapis.com/minikube/k8sReleases/v1.7.0/localkube-linux-amd64"
|
||||
chmod +x localkube
|
||||
sudo nohup ./localkube --logtostderr=true --enable-dns=false > localkube.log 2>&1 &
|
||||
|
||||
echo "Waiting for localkube to start..."
|
||||
if ! timeout 120 sh -c "while ! curl -ks http://127.0.0.1:8080/ >/dev/null; do sleep 1; done"; then
|
||||
sudo cat localkube.log
|
||||
echo "localkube did not start"
|
||||
exit 1
|
||||
fi
|
||||
echo "Check certificate permissions"
|
||||
sudo chmod 644 /var/lib/localkube/certs/*
|
||||
sudo ls -altr /var/lib/localkube/certs/
|
||||
|
||||
echo "Set up .kube/config"
|
||||
mkdir ~/.kube
|
||||
echo -e "apiVersion: v1\nclusters:\n- cluster:\n certificate-authority: /var/lib/localkube/certs/ca.crt\n server: https://127.0.0.1:8443\n name: local\ncontexts:\n- context:\n cluster: local\n user: myself\n name: local\ncurrent-context: local\nkind: Config\npreferences: {}\nusers:\n- name: myself\n user:\n client-certificate: /var/lib/localkube/certs/apiserver.crt\n client-key: /var/lib/localkube/certs/apiserver.key\n" > ~/.kube/config
|
||||
}
|
||||
|
||||
function get_exhibitor() {
|
||||
ZC=~/mycache/zookeeper-${ZKVERSION}
|
||||
if [[ ! -d $ZC ]]; then
|
||||
curl -L http://www.apache.org/dist/zookeeper/zookeeper-${ZKVERSION}/zookeeper-${ZKVERSION}.tar.gz | tar xz
|
||||
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
|
||||
mv zookeeper-${ZKVERSION}/conf/zoo_sample.cfg zookeeper-${ZKVERSION}/conf/zoo.cfg
|
||||
mv zookeeper-${ZKVERSION} $ZC
|
||||
fi
|
||||
$ZC/bin/zkServer.sh start
|
||||
# following lines are 'emulating' exhibitor REST API
|
||||
while true; do
|
||||
echo -e 'HTTP/1.0 200 OK\nContent-Type: application/json\n\n{"servers":["127.0.0.1"],"port":2181}' \
|
||||
| nc -l 8181 &> /dev/null
|
||||
done&
|
||||
}
|
||||
|
||||
function get_raft() {
|
||||
return 0
|
||||
}
|
||||
|
||||
attempt_num=1
|
||||
until get_${DCS}; do
|
||||
[[ $attempt_num -ge 3 ]] && exit 1
|
||||
echo "Attempt $attempt_num failed! Trying again in $attempt_num seconds..."
|
||||
sleep $(( attempt_num++ ))
|
||||
done
|
||||
fi
|
||||
|
||||
for pv in $PYVERSIONS; do
|
||||
fpv=$(basename $(readlink $HOME/virtualenv/python$pv))
|
||||
if [[ -d ~/mycache/${fpv} ]]; then
|
||||
mv ~/virtualenv/${fpv} ~/virtualenv/${fpv}.bckp
|
||||
mv ~/mycache/${fpv} ~/virtualenv/${fpv}
|
||||
fi
|
||||
source ~/virtualenv/python${pv}/bin/activate
|
||||
# explicitly install all needed python modules to cache them
|
||||
for p in '-r requirements.txt' 'psycopg2-binary behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest setuptools'; do
|
||||
pip install $p --upgrade
|
||||
done
|
||||
done
|
||||
script:
|
||||
- |
|
||||
for pv in $PYVERSIONS; do
|
||||
source ~/virtualenv/python${pv}/bin/activate
|
||||
|
||||
if [[ $TEST_SUITE = "behave" ]]; then
|
||||
echo Running integration tests using python${pv}
|
||||
if ! PATH=.:/usr/lib/postgresql/9.6/bin:$PATH unbuffer $TEST_SUITE; then
|
||||
# output all log files when tests are failing
|
||||
grep . features/output/*_failed/*postgres?.*
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo Running unit tests using python${pv}
|
||||
unbuffer $TEST_SUITE test
|
||||
$TEST_SUITE flake8
|
||||
fi
|
||||
mv .coverage /tmp/.coverage.$pv
|
||||
done
|
||||
mv /tmp/.coverage.* .
|
||||
python -m coverage combine
|
||||
|
||||
set +e
|
||||
after_success:
|
||||
# before_cache is executed earlier than after_success, so we need to restore one of virtualenv directories
|
||||
- fpv=$(basename $(readlink $HOME/virtualenv/python3.6)) && mv $HOME/mycache/${fpv} $HOME/virtualenv/${fpv}
|
||||
- coveralls
|
||||
- if [[ $TEST_SUITE != "behave" ]]; then python-codacy-coverage -r coverage.xml; fi
|
||||
- if [[ $DCS == "exhibitor" ]]; then ~/mycache/zookeeper-${ZKVERSION}/bin/zkServer.sh stop; fi
|
||||
- sudo kill $(jobs -p)
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
|Build Status| |Coverage Status|
|
||||
|Tests Status| |Coverage Status|
|
||||
|
||||
Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
|
||||
--------------------------------------------------------------------
|
||||
@@ -12,6 +12,8 @@ Patroni is a template for you to create your own customized, high-availability s
|
||||
|
||||
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 13.
|
||||
|
||||
**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.
|
||||
|
||||
.. contents::
|
||||
@@ -117,7 +119,7 @@ For example, the command in order to install Patroni together with dependencies
|
||||
|
||||
pip install patroni[etcd,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstap scripts (i.e. WAL-E) should be installed independently of Patroni.
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
|
||||
|
||||
=======================
|
||||
Running and Configuring
|
||||
@@ -169,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.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
||||
:target: https://travis-ci.org/zalando/patroni
|
||||
.. |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/r/zalando/patroni?branch=master
|
||||
:target: https://coveralls.io/github/zalando/patroni?branch=master
|
||||
|
||||
@@ -10,6 +10,39 @@ Chatting
|
||||
|
||||
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel #patroni in the `PostgreSQL Slack <https://postgres-slack.herokuapp.com/>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
Requirements for running behave tests:
|
||||
|
||||
1. PostgreSQL packages need to be installed.
|
||||
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify pip3.
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
After you have all dependencies installed, you can run the various test suites:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# You may want to use Virtualenv or specify python3.
|
||||
|
||||
# Run flake8 to check syntax and formatting:
|
||||
python setup.py flake8
|
||||
|
||||
# Run the pytest suite in tests/:
|
||||
python setup.py test
|
||||
|
||||
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
|
||||
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
|
||||
DCS=raft python -m behave
|
||||
|
||||
Reporting issues
|
||||
----------------
|
||||
|
||||
|
||||
+13
-2
@@ -59,7 +59,8 @@ Etcd
|
||||
- **PATRONI\_ETCD\_USE\_PROXIES**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster but stick to a fixed list of **hosts**.
|
||||
- **PATRONI\_ETCD\_PROTOCOL**: http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **PATRONI\_ETCD\_HOST**: the host:port for the etcd endpoint.
|
||||
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
|
||||
- **PATRONI\_ETCD\_SRV\_SUFFIX**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **PATRONI\_ETCD\_SRV**. For example, if ``PATRONI_ETCD_SRV_SUFFIX=foo`` and ``PATRONI_ETCD_SRV=example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
|
||||
- **PATRONI\_ETCD\_USERNAME**: username for etcd authentication.
|
||||
- **PATRONI\_ETCD\_PASSWORD**: password for etcd authentication.
|
||||
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If present it will enable validation.
|
||||
@@ -105,13 +106,16 @@ Kubernetes
|
||||
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
|
||||
- **PATRONI\_KUBERNETES\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
|
||||
Raft
|
||||
----
|
||||
|
||||
- **PATRONI\_RAFT\_SELF\_ADDR**: ``ip:port`` to listen on for Raft connections. If not set, the node will not participate in consensus.
|
||||
- **PATRONI\_RAFT\_SELF\_ADDR**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
|
||||
- **PATRONI\_RAFT\_BIND\_ADDR**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
|
||||
- **PATRONI\_RAFT\_PARTNER\_ADDRS**: list of other Patroni nodes in the cluster in format ``"'ip1:port1','ip2:port2'"``. It is important to quote every single entity!
|
||||
- **PATRONI\_RAFT\_DATA\_DIR**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
|
||||
- **PATRONI\_RAFT\_PASSWORD**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
|
||||
|
||||
PostgreSQL
|
||||
----------
|
||||
@@ -125,6 +129,7 @@ PostgreSQL
|
||||
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
|
||||
- **PATRONI\_REPLICATION\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **PATRONI\_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\_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.
|
||||
@@ -134,6 +139,7 @@ PostgreSQL
|
||||
- **PATRONI\_SUPERUSER\_PASSWORD**: password for the superuser, set during initialization (initdb).
|
||||
- **PATRONI\_SUPERUSER\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **PATRONI\_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\_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.
|
||||
@@ -143,6 +149,7 @@ PostgreSQL
|
||||
- **PATRONI\_REWIND\_PASSWORD**: password for the user for ``pg_rewind``; the user will be created during initialization.
|
||||
- **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **PATRONI\_REWIND\_SSLKEY**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **PATRONI\_REWIND\_SSLPASSWORD**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``PATRONI_REWIND_SSLKEY``.
|
||||
- **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\_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.
|
||||
@@ -157,8 +164,12 @@ REST API
|
||||
- **PATRONI\_RESTAPI\_PASSWORD**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
- **PATRONI\_RESTAPI\_CERTFILE**: Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **PATRONI\_RESTAPI\_KEYFILE**: Specifies the file with the secret key in the PEM format.
|
||||
- **PATRONI\_RESTAPI\_KEYFILE\_PASSWORD**: Specifies a password for decrypting the keyfile.
|
||||
- **PATRONI\_RESTAPI\_CAFILE**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
|
||||
- **PATRONI\_RESTAPI\_CIPHERS**: (optional) Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
|
||||
- **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
|
||||
- **PATRONI\_RESTAPI\_ALLOWLIST**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
|
||||
- **PATRONI\_RESTAPI\_ALLOWLIST\_INCLUDE\_MEMBERS**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
|
||||
|
||||
@@ -163,3 +163,20 @@ When connecting from an application, always use a non-superuser. Patroni require
|
||||
:target: https://travis-ci.org/zalando/patroni
|
||||
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/zalando/patroni?branch=master
|
||||
|
||||
Testing Your HA Solution
|
||||
--------------------------------------
|
||||
Testing an HA solution is a time consuming process, with many variables. This is particularly true considering a cross-platform application. You need a trained system administrator or a consultant to do this work. It is not something we can cover in depth in the documentation.
|
||||
|
||||
That said, here are some pieces of your infrastructure you should be sure to test:
|
||||
|
||||
* Network (the network in front of your system as well as the NICs [physical or virtual] themselves)
|
||||
* Disk IO
|
||||
* file limits (nofile in Linux)
|
||||
* RAM. Even if you have oomkiller turned off as suggested, the unavailability of RAM could cause issues.
|
||||
* CPU
|
||||
* Virtualization Contention (overcommitting the hypervisor)
|
||||
* Any cgroup limitation (likely to be related to the above)
|
||||
* ``kill -9`` of any postgres process (except postmaster!). This is a decent simulation of a segfault.
|
||||
|
||||
One thing that you should not do is run ``kill -9`` on a postmaster process. This is because doing so does not mimic any real life scenario. If you are concerned your infrastructure is insecure and an attacker could run ``kill -9``, no amount of HA process is going to fix that. The attacker will simply kill the process again, or cause chaos in another way.
|
||||
|
||||
+55
-5
@@ -15,6 +15,7 @@ Dynamic configuration is stored in the DCS (Distributed Configuration Store) and
|
||||
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
|
||||
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
|
||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
|
||||
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
||||
- **master\_start\_timeout**: the amount of time a master 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 master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||
- **master\_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 master_stop_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, master_stop_timeout does not apply.
|
||||
@@ -33,11 +34,37 @@ Dynamic configuration is stored in the DCS (Distributed Configuration Store) and
|
||||
- **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings`
|
||||
- **archive\_cleanup\_command**: cleanup command for standby leader
|
||||
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Patroni will try to create slots before opening connections to the cluster.
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
- **my_slot_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
|
||||
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
|
||||
- **database**: the database name where logical slots should be created.
|
||||
- **plugin**: the plugin name for the logical slot.
|
||||
- **ignore_slots**: list of sets of replication slot properties for which Patroni should ignore matching slots. This configuration/feature/etc. is useful when some replication slots are managed outside of Patroni. Any subset of matching properties will cause a slot to be ignored.
|
||||
- **name**: the name of the replication slot.
|
||||
- **type**: slot type. Can be ``physical`` or ``logical``. If the slot is logical, you may additionally define ``database`` and/or ``plugin``.
|
||||
- **database**: the database name (when matching a ``logical`` slot).
|
||||
- **plugin**: the logical decoding plugin (when matching a ``logical`` slot).
|
||||
|
||||
Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
slots:
|
||||
permanent_logical_slot_name:
|
||||
type: logical
|
||||
database: my_db
|
||||
plugin: test_decoding
|
||||
permanent_physical_slot_name:
|
||||
type: physical
|
||||
...
|
||||
ignore_slots:
|
||||
- name: ignored_logical_slot_name
|
||||
type: logical
|
||||
database: my_db
|
||||
plugin: test_decoding
|
||||
- name: ignored_physical_slot_name
|
||||
type: physical
|
||||
...
|
||||
|
||||
Global/Universal
|
||||
----------------
|
||||
@@ -129,7 +156,8 @@ Most of the parameters are optional, but you have to specify one of the **host**
|
||||
- **use\_proxies**: If this parameter is set to true, Patroni will consider **hosts** as a list of proxies and will not perform a topology discovery of etcd cluster.
|
||||
- **url**: url for the etcd.
|
||||
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**.
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery. Patroni will try to query these SRV service names for specified domain (in that order until first success): ``_etcd-client-ssl``, ``_etcd-client``, ``_etcd-ssl``, ``_etcd``, ``_etcd-server-ssl``, ``_etcd-server``. If SRV records for ``_etcd-server-ssl`` or ``_etcd-server`` are retrieved then ETCD peer protocol is used do query ETCD for available members. Otherwise hosts from SRV records will be used.
|
||||
- **srv\_suffix**: Configures a suffix to the SRV name that is queried during discovery. Use this flag to differentiate between multiple etcd clusters under the same domain. Works only with conjunction with **srv**. For example, if ``srv_suffix: foo`` and ``srv: example.org`` are set, the following DNS SRV query is made:``_etcd-client-ssl-foo._tcp.example.com`` (and so on for every possible ETCD SRV service name).
|
||||
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **username**: (optional) username for etcd authentication.
|
||||
- **password**: (optional) password for etcd authentication.
|
||||
@@ -177,15 +205,18 @@ Kubernetes
|
||||
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
||||
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
|
||||
- **cacert**: (optional) Specifies the file with the CA_BUNDLE file with certificates of trusted CAs to use while verifying Kubernetes API SSL certs. If not provided, patroni will use the value provided by the ServiceAccount secret.
|
||||
|
||||
|
||||
.. _raft_settings:
|
||||
|
||||
Raft
|
||||
----
|
||||
- **self\_addr**: ``ip:port`` to listen on for Raft connections. If not set, the node will not participate in consensus.
|
||||
- **self\_addr**: ``ip:port`` to listen on for Raft connections. The ``self_addr`` must be accessible from other nodes of the cluster. If not set, the node will not participate in consensus.
|
||||
- **bind\_addr**: (optional) ``ip:port`` to listen on for Raft connections. If not specified the ``self_addr`` will be used.
|
||||
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
|
||||
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
|
||||
- **password**: (optional) Encrypt Raft traffic with a specified password, requires ``cryptography`` python module.
|
||||
|
||||
Short FAQ about Raft implementation
|
||||
|
||||
@@ -199,7 +230,7 @@ Raft
|
||||
|
||||
- Q: Where to get the ``syncobj_admin`` utility?
|
||||
|
||||
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependancy.
|
||||
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
|
||||
|
||||
- Q: it is possible to run Patroni node without adding in to the consensus?
|
||||
|
||||
@@ -207,7 +238,7 @@ Raft
|
||||
|
||||
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
|
||||
|
||||
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such setup one can temporary loose one node without affecting the primary.
|
||||
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such a setup, one can temporarily lose one node without affecting the primary.
|
||||
|
||||
|
||||
.. _postgresql_settings:
|
||||
@@ -221,6 +252,7 @@ PostgreSQL
|
||||
- **password**: password for the superuser, set during initialization (initdb).
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
@@ -231,6 +263,7 @@ PostgreSQL
|
||||
- **password**: replication password; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
@@ -241,6 +274,7 @@ PostgreSQL
|
||||
- **password**: password for the user for ``pg_rewind``; the user will be created during initialization.
|
||||
- **sslmode**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
|
||||
- **sslkey**: (optional) maps to the `sslkey <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLKEY>`__ connection parameter, which specifies the location of the secret key used with the client's certificate.
|
||||
- **sslpassword**: (optional) maps to the `sslpassword <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLPASSWORD>`__ connection parameter, which specifies the password for the secret key specified in ``sslkey``.
|
||||
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
|
||||
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
|
||||
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
|
||||
@@ -261,6 +295,7 @@ PostgreSQL
|
||||
- **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
|
||||
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
|
||||
@@ -290,8 +325,12 @@ REST API
|
||||
- **password**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
|
||||
- **keyfile_password**: (optional): Specifies a password for decrypting the keyfile.
|
||||
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
|
||||
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
|
||||
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
|
||||
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
|
||||
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
|
||||
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
|
||||
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
|
||||
|
||||
@@ -331,6 +370,8 @@ Watchdog
|
||||
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
|
||||
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
|
||||
|
||||
.. _tags_settings:
|
||||
|
||||
Tags
|
||||
----
|
||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
|
||||
@@ -338,3 +379,12 @@ Tags
|
||||
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
||||
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||
|
||||
In addition to these predefined tags, you can also add your own ones:
|
||||
|
||||
- **key1**: ``true``
|
||||
- **key2**: ``false``
|
||||
- **key3**: ``1.4``
|
||||
- **key4**: ``"RandomString"``
|
||||
|
||||
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
|
||||
@@ -10,6 +10,8 @@ Patroni is a template for you to create your own customized, high-availability s
|
||||
|
||||
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 13.
|
||||
|
||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation.
|
||||
|
||||
|
||||
@@ -20,6 +22,7 @@ We call Patroni a "template" because it is far from being a one-size-fits-all or
|
||||
README
|
||||
dynamic_configuration
|
||||
rest_api
|
||||
existing_data
|
||||
ENVIRONMENT
|
||||
SETTINGS
|
||||
security
|
||||
|
||||
+283
-11
@@ -3,6 +3,278 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 2.1.1
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Support for ETCD SRV name suffix (David Pavlicek)
|
||||
|
||||
Etcd allows to differentiate between multiple Etcd clusters under the same domain and from now on Patroni also supports it.
|
||||
|
||||
- Enrich history with the new leader (huiyalin525)
|
||||
|
||||
It adds the new column to the ``patronictl history`` output.
|
||||
|
||||
- Make the CA bundle configurable for in-cluster Kubernetes config (Aron Parsons)
|
||||
|
||||
By default Patroni is using ``/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`` and this new feature allows specifying the custom ``kubernetes.cacert``.
|
||||
|
||||
- Support dynamically registering/deregistering as a Consul service and changing tags (Tommy Li)
|
||||
|
||||
Previously it required Patroni restart.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Avoid unnecessary reload of REST API (Alexander Kukushkin)
|
||||
|
||||
The previous release added a feature of reloading REST API certificates if changed on disk. Unfortunately, the reload was happening unconditionally right after the start.
|
||||
|
||||
- Don't resolve cluster members when ``etcd.use_proxies`` is set (Alexander)
|
||||
|
||||
When starting up Patroni checks the healthiness of Etcd cluster by querying the list of members. In addition to that, it also tried to resolve their hostnames, which is not necessary when working with Etcd via proxy and was causing unnecessary warnings.
|
||||
|
||||
- Skip rows with NULL values in the ``pg_stat_replication`` (Alexander)
|
||||
|
||||
It seems that the ``pg_stat_replication`` view could contain NULL values in the ``replay_lsn``, ``flush_lsn``, or ``write_lsn`` fields even when ``state = 'streaming'``.
|
||||
|
||||
|
||||
Version 2.1.0
|
||||
-------------
|
||||
|
||||
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**
|
||||
|
||||
- Compatibility with PostgreSQL v14 (Alexander Kukushkin)
|
||||
|
||||
Unpause WAL replay if Patroni is not in a "pause" mode itself. It could be "paused" due to the change of certain parameters like for example ``max_connections`` on the primary.
|
||||
|
||||
- Failover logical slots (Alexander)
|
||||
|
||||
Make logical replication slots survive failover/switchover on PostgreSQL v11+. The replication slot if copied from the primary to the replica with restart and later the `pg_replication_slot_advance() <https://www.postgresql.org/docs/11/functions-admin.html#id-1.5.8.31.8.5.2.2.8.1.1>`__ function is used to move it forward. As a result, the slot will already exist before the failover and no events should be lost, but, there is a chance that some events could be delivered more than once.
|
||||
|
||||
- Implemented allowlist for Patroni REST API (Alexander)
|
||||
|
||||
If configured, only IP's that matching rules would be allowed to call unsafe endpoints. In addition to that, it is possible to automatically include IP's of members of the cluster to the list.
|
||||
|
||||
- Added support of replication connections via unix socket (Mohamad El-Rifai)
|
||||
|
||||
Previously Patroni was always using TCP for replication connection what could cause some issues with SSL verification. Using unix sockets allows exempt replication user from SSL verification.
|
||||
|
||||
- Health check on user-defined tags (Arman Jafari Tehrani)
|
||||
|
||||
Along with :ref:`predefined tags: <tags_settings>` it is possible to specify any number of custom tags that become visible in the ``patronictl list`` output and in the REST API. From now on it is possible to use custom tags in health checks.
|
||||
|
||||
- Added Prometheus ``/metrics`` endpoint (Mark Mercado, Michael Banck)
|
||||
|
||||
The endpoint exposing the same metrics as ``/patroni``.
|
||||
|
||||
- Reduced chattiness of Patroni logs (Alexander)
|
||||
|
||||
When everything goes normal, only one line will be written for every run of HA loop.
|
||||
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
- The old ``permanent logical replication slots`` feature will no longer work with PostgreSQL v10 and older (Alexander)
|
||||
|
||||
The strategy of creating the logical slots after performing a promotion can't guaranty that no logical events are lost and therefore disabled.
|
||||
|
||||
- The ``/leader`` endpoint always returns 200 if the node holds the lock (Alexander)
|
||||
|
||||
Promoting the standby cluster requires updating load-balancer health checks, which is not very convenient and easy to forget. To solve it, we change the behavior of the ``/leader`` health check endpoint. It will return 200 without taking into account whether the cluster is normal or the ``standby_cluster``.
|
||||
|
||||
|
||||
**Improvements in Raft support**
|
||||
|
||||
- Reliable support of Raft traffic encryption (Alexander)
|
||||
|
||||
Due to the different issues in the ``PySyncObj`` the encryption support was very unstable
|
||||
|
||||
- Handle DNS issues in Raft implementation (Alexander)
|
||||
|
||||
If ``self_addr`` and/or ``partner_addrs`` are configured using the DNS name instead of IP's the ``PySyncObj`` was effectively doing resolve only once when the object is created. It was causing problems when the same node was coming back online with a different IP.
|
||||
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Compatibility with ``psycopg2-2.9+`` (Alexander)
|
||||
|
||||
In ``psycopg2`` the ``autocommit = True`` is ignored in the ``with connection`` block, which breaks replication protocol connections.
|
||||
|
||||
- Fix excessive HA loop runs with Zookeeper (Alexander)
|
||||
|
||||
Update of member ZNodes was causing a chain reaction and resulted in running the HA loops multiple times in a row.
|
||||
|
||||
- Reload if REST API certificate is changed on disk (Michael Todorovic)
|
||||
|
||||
If the REST API certificate file was updated in place Patroni didn't perform a reload.
|
||||
|
||||
- Don't create pgpass dir if kerberos auth is used (Kostiantyn Nemchenko)
|
||||
|
||||
Kerberos and password authentication are mutually exclusive.
|
||||
|
||||
- Fixed little issues with custom bootstrap (Alexander)
|
||||
|
||||
Start Postgres with ``hot_standby=off`` only when we do a PITR and restart it after PITR is done.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Compatibility with ``kazoo-2.7+`` (Alexander)
|
||||
|
||||
Since Patroni is handling retries on its own, it is relying on the old behavior of ``kazoo`` that requests to a Zookeeper cluster are immediately discarded when there are no connections available.
|
||||
|
||||
- Explicitly request the version of Etcd v3 cluster when it is known that we are connecting via proxy (Alexander)
|
||||
|
||||
Patroni is working with Etcd v3 cluster via gPRC-gateway and it depending on the cluster version different endpoints (``/v3``, ``/v3beta``, or ``/v3alpha``) must be used. The version was resolved only together with the cluster topology, but since the latter was never done when connecting via proxy.
|
||||
|
||||
|
||||
Version 2.0.2
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Ability to ignore externally managed replication slots (James Coleman)
|
||||
|
||||
Patroni is trying to remove any replication slot which is unknown to it, but there are certainly cases when replication slots should be managed externally. From now on it is possible to configure slots that should not be removed.
|
||||
|
||||
- Added support for cipher suite limitation for REST API (Gunnar "Nick" Bluth)
|
||||
|
||||
It could be configured via ``restapi.ciphers`` or the ``PATRONI_RESTAPI_CIPHERS`` environment variable.
|
||||
|
||||
- Added support for encrypted TLS keys for REST API (Jonathan S. Katz)
|
||||
|
||||
It could be configured via ``restapi.keyfile_password`` or the ``PATRONI_RESTAPI_KEYFILE_PASSWORD`` environment variable.
|
||||
|
||||
- Constant time comparison of REST API authentication credentials (Alex Brasetvik)
|
||||
|
||||
Use ``hmac.compare_digest()`` instead of ``==``, which is vulnerable to timing attack.
|
||||
|
||||
- Choose synchronous nodes based on replication lag (Krishna Sarabu)
|
||||
|
||||
If the replication lag on the synchronous node starts exceeding the configured threshold it could be demoted to asynchronous and/or replaced by the other node. Behaviour is controlled with ``maximum_lag_on_syncnode``.
|
||||
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Start postgres with ``hot_standby = off`` when doing custom bootstrap (Igor Yanchenko)
|
||||
|
||||
During custom bootstrap Patroni is restoring the basebackup, starting Postgres up, and waiting until recovery finishes. Some PostgreSQL parameters on the standby can't be smaller than on the primary and if the new value (restored from WAL) is higher than the configured one, Postgres panics and stops. In order to avoid such behavior we will do custom bootstrap without ``hot_standby`` mode.
|
||||
|
||||
- Warn the user if the required watchdog is not healthy (Nicolas Thauvin)
|
||||
|
||||
When the watchdog device is not writable or missing in required mode, the member cannot be promoted. Added a warning to show the user where to search for this misconfiguration.
|
||||
|
||||
- Better verbosity for single-user mode recovery (Alexander Kukushkin)
|
||||
|
||||
If Patroni notices that PostgreSQL wasn't shutdown clearly, in certain cases the crash-recovery is executed by starting Postgres in single-user mode. It could happen that the recovery failed (for example due to the lack of space on disk) but errors were swallowed.
|
||||
|
||||
- Added compatibility with ``python-consul2`` module (Alexander, Wilfried Roset)
|
||||
|
||||
The good old ``python-consul`` is not maintained since a few years, therefore someone created a fork with new features and bug-fixes.
|
||||
|
||||
- Don't use ``bypass_api_service`` when running ``patronictl`` (Alexander)
|
||||
|
||||
When a K8s pod is running in a non-``default`` namespace it does not necessarily have enough permissions to query the ``kubernetes`` endpoint. In this case Patroni shows the warning and ignores the ``bypass_api_service`` setting. In case of ``patronictl`` the warning was a bit annoying.
|
||||
|
||||
- Create ``raft.data_dir`` if it doesn't exists or make sure that it is writable (Mark Mercado)
|
||||
|
||||
Improves user-friendliness and usability.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Don't interrupt restart or promote if lost leader lock in pause (Alexander)
|
||||
|
||||
In pause it is allowed to run postgres as primary without lock.
|
||||
|
||||
- Fixed issue with ``shutdown_request()`` in the REST API (Nicolas Limage)
|
||||
|
||||
In order to improve handling of SSL connections and delay the handshake until thread is started Patroni overrides a few methods in the ``HTTPServer``. The ``shutdown_request()`` method was forgotten.
|
||||
|
||||
- Fixed issue with sleep time when using Zookeeper (Alexander)
|
||||
|
||||
There were chances that Patroni was sleeping up to twice longer between running HA code.
|
||||
|
||||
- Fixed invalid ``os.symlink()`` calls when moving data directory after failed bootstrap (Andrew L'Ecuyer)
|
||||
|
||||
If the bootstrap failed Patroni is renaming data directory, pg_wal, and all tablespaces. After that it updates symlinks so filesystem remains consistent. The symlink creation was failing due to the ``src`` and ``dst`` arguments being swapped.
|
||||
|
||||
- Fixed bug in the post_bootstrap() method (Alexander)
|
||||
|
||||
If the superuser password wasn't configured Patroni was failing to call the ``post_init`` script and therefore the whole bootstrap was failing.
|
||||
|
||||
- Fixed an issues with pg_rewind in the standby cluster (Alexander)
|
||||
|
||||
If the superuser name is different from Postgres, the ``pg_rewind`` in the standby cluster was failing because the connection string didn't contain the database name.
|
||||
|
||||
- Exit only if authentication with Etcd v3 explicitly failed (Alexander)
|
||||
|
||||
On start Patroni performs discovery of Etcd cluster topology and authenticates if it is necessarily. It could happen that one of etcd servers is not accessible, Patroni was trying to perform authentication on this server and failing instead of retrying with the next node.
|
||||
|
||||
- Handle case with psutil cmdline() returning empty list (Alexander)
|
||||
|
||||
Zombie processes are still postmasters children, but they don't have cmdline()
|
||||
|
||||
- Treat ``PATRONI_KUBERNETES_USE_ENDPOINTS`` environment variable as boolean (Alexander)
|
||||
|
||||
Not doing so was making impossible disabling ``kubernetes.use_endpoints`` via environment.
|
||||
|
||||
- Improve handling of concurrent endpoint update errors (Alexander)
|
||||
|
||||
Patroni will explicitly query the current endpoint object, verify that the current pod still holds the leader lock and repeat the update.
|
||||
|
||||
|
||||
Version 2.0.1
|
||||
-------------
|
||||
|
||||
**New features**
|
||||
|
||||
- Use ``more`` as pager in ``patronictl edit-config`` if ``less`` is not available (Pavel Golub)
|
||||
|
||||
On Windows it would be the ``more.com``. In addition to that, ``cdiff`` was changed to ``ydiff`` in ``requirements.txt``, but ``patronictl`` still supports both for compatibility.
|
||||
|
||||
- Added support of ``raft`` ``bind_addr`` and ``password`` (Alexander Kukushkin)
|
||||
|
||||
``raft.bind_addr`` might be useful when running behind NAT. ``raft.password`` enables traffic encryption (requires the ``cryptography`` module).
|
||||
|
||||
- Added ``sslpassword`` connection parameter support (Kostiantyn Nemchenko)
|
||||
|
||||
The connection parameter was introduced in PostgreSQL 13.
|
||||
|
||||
**Stability improvements**
|
||||
|
||||
- Changed the behavior in pause (Alexander)
|
||||
|
||||
1. Patroni will not call the ``bootstrap`` method if the ``PGDATA`` directory is missing/empty.
|
||||
2. Patroni will not exit on sysid mismatch in pause, only log a warning.
|
||||
3. The node will not try to grab the leader key in pause mode if Postgres is running not in recovery (accepting writes) but the sysid doesn't match with the initialize key.
|
||||
|
||||
- Apply ``master_start_timeout`` when executing crash recovery (Alexander)
|
||||
|
||||
If Postgres crashed on the leader node, Patroni does a crash-recovery by starting Postgres in single-user mode. During the crash-recovery the leader lock is being updated. If the crash-recovery didn't finish in ``master_start_timeout`` seconds, Patroni will stop it forcefully and release the leader lock.
|
||||
|
||||
- Removed the ``secure`` extra from the ``urllib3`` requirements (Alexander)
|
||||
|
||||
The only reason for adding it there was the ``ipaddress`` dependency for python 2.7.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed a bug in the ``Kubernetes.update_leader()`` (Alexander)
|
||||
|
||||
An unhandled exception was preventing demoting the primary when the update of the leader object failed.
|
||||
|
||||
- Fixed hanging ``patronictl`` when RAFT is being used (Alexander)
|
||||
|
||||
When using ``patronictl`` with Patroni config, ``self_addr`` should be added to the ``partner_addrs``.
|
||||
|
||||
- Fixed bug in ``get_guc_value()`` (Alexander)
|
||||
|
||||
Patroni was failing to get the value of ``restore_command`` on PostgreSQL 12, therefore fetching missing WALs for ``pg_rewind`` didn't work.
|
||||
|
||||
|
||||
Version 2.0.0
|
||||
-------------
|
||||
|
||||
@@ -538,7 +810,7 @@ Version 1.6.1
|
||||
|
||||
- Some improvements in logging infrastructure (Alexander Kukushkin)
|
||||
|
||||
Previously threre was a possibility to loose the last few log lines on shutdown because the logging thread was a ``daemon`` thread.
|
||||
Previously there was a possibility to loose the last few log lines on shutdown because the logging thread was a ``daemon`` thread.
|
||||
|
||||
- Use ``spawn`` multiprocessing start method on python 3.4+ (Maciej Kowalczyk)
|
||||
|
||||
@@ -588,7 +860,7 @@ Version 1.6.1
|
||||
|
||||
If the method is executed from the REST API thread, it requires a separate cursor object to be created.
|
||||
|
||||
- Fix the problem of not promoting the sync standby that had a name contaning upper case letters (Alexander Kukushkin)
|
||||
- Fix the problem of not promoting the sync standby that had a name containing upper case letters (Alexander Kukushkin)
|
||||
|
||||
We converted the name to the lower case because Postgres was doing the same while comparing the ``application_name`` with the value in ``synchronous_standby_names``.
|
||||
|
||||
@@ -864,7 +1136,7 @@ Compatibility and bugfix release.
|
||||
|
||||
- Fix broken compatibility with postgres 9.3 (Alexander)
|
||||
|
||||
When opening a replication connection we should specify replication=1, beacuse 9.3 does not understand replication='database'
|
||||
When opening a replication connection we should specify replication=1, because 9.3 does not understand replication='database'
|
||||
|
||||
- Make sure we refresh Consul session at least once per HA loop and improve handling of consul sessions exceptions (Alexander)
|
||||
|
||||
@@ -949,7 +1221,7 @@ This version enables Patroni HA cluster to operate in a standby mode, introduces
|
||||
|
||||
- Immediately reserve the WAL position upon creation of the replication slot (Alexander Kukushkin)
|
||||
|
||||
Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the intiial client connection.
|
||||
Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the initial client connection.
|
||||
|
||||
- Fix bug in strict synchronous replication (Alexander Kukushkin)
|
||||
|
||||
@@ -1189,7 +1461,7 @@ This version adds support for using Kubernetes as a DCS, allowing to run Patroni
|
||||
|
||||
**Upgrade notice**
|
||||
|
||||
Installing Patroni via pip will no longer bring in dependencies for (such as libraries for Etcd, Zookeper, Consul or Kubernetes, or support for AWS). In order to enable them one need to list them in pip install command explicitely, for instance `pip install patroni[etcd,kubernetes]`.
|
||||
Installing Patroni via pip will no longer bring in dependencies for (such as libraries for Etcd, Zookeper, Consul or Kubernetes, or support for AWS). In order to enable them one need to list them in pip install command explicitly, for instance `pip install patroni[etcd,kubernetes]`.
|
||||
|
||||
**Kubernetes support**
|
||||
|
||||
@@ -1208,7 +1480,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Remove leader key on shutdown only when we have the lock (Ants)
|
||||
|
||||
Unconditional removal was generating unnecessary and missleading exceptions.
|
||||
Unconditional removal was generating unnecessary and misleading exceptions.
|
||||
|
||||
**Improvements in patronictl**
|
||||
|
||||
@@ -1239,7 +1511,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Alter the behavior of ``patronictl failover`` (Alexander)
|
||||
|
||||
It will work even if there is no leader, but in that case you will have to explicitely specify a node which should become the new leader.
|
||||
It will work even if there is no leader, but in that case you will have to explicitly specify a node which should become the new leader.
|
||||
|
||||
**Expose information about timeline and history**
|
||||
|
||||
@@ -1255,7 +1527,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
||||
|
||||
- Add new /sync and /async endpoints (Alexander, Oleksii Kliukin)
|
||||
|
||||
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchornous replicas correspondingly (exclusing those marked as `noloadbalance`).
|
||||
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchronous replicas correspondingly (exclusing those marked as `noloadbalance`).
|
||||
|
||||
**Allow multiple hosts for Etcd**
|
||||
|
||||
@@ -1343,7 +1615,7 @@ Version 1.3.4
|
||||
|
||||
- Pass the consul token as a header (Andrew Colin Kissa)
|
||||
|
||||
Headers are now the prefered way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
|
||||
Headers are now the preferred way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
|
||||
|
||||
|
||||
- Advanced configuration for Consul (Alexander Kukushkin)
|
||||
@@ -1661,7 +1933,7 @@ In addition, patronictl supports new ``pause`` and ``resume`` commands to toggle
|
||||
Originally, ping_timeout and connect_timeout values were calculated from the negotiated session timeout. Patroni loop_wait was not taken into account. As
|
||||
a result, a single retry could take more time than the session timeout, forcing Patroni to release the lock and demote.
|
||||
|
||||
This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before loosing the lock.
|
||||
This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before losing the lock.
|
||||
|
||||
- Update Etcd topology only after original request succeed (Alexander)
|
||||
|
||||
@@ -1739,7 +2011,7 @@ When upgrading from v0.90 or below, always upgrade all replicas before the maste
|
||||
|
||||
See the :ref:`dynamic configuration <dynamic_configuration>` for the details on which parameters can be changed and the order of processing difference configuration sources.
|
||||
|
||||
The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuraton documentation page <dynamic_configuration>`.
|
||||
The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuration documentation page <dynamic_configuration>`.
|
||||
|
||||
**More flexible configuration***
|
||||
|
||||
|
||||
@@ -143,6 +143,10 @@ possible to specify a ``basebackup`` configuration section. Same rules as with t
|
||||
namely, only long (with --) options should be specified there. Not all parameters make sense, if you override a connection
|
||||
string or provide an option to created tar-ed or compressed base backups, patroni won't be able to make a replica out
|
||||
of it. There is no validation performed on the names or values of the parameters passed to the ``basebackup`` section.
|
||||
Also note that in case symlinks are used for the WAL folder it is up to the user to specify the correct ``--waldir``
|
||||
path as an option, so that after replica buildup or re-initialization the symlink would persist. This option is supported
|
||||
only since v10 though.
|
||||
|
||||
You can specify basebackup parameters as either a map (key-value pairs) or a list of elements, where each element
|
||||
could be either a key-value pair or a single key (for options that does not receive any values, for instance, ``--verbose``).
|
||||
Consider those 2 examples:
|
||||
@@ -162,6 +166,7 @@ and
|
||||
basebackup:
|
||||
- verbose
|
||||
- max-rate: '100M'
|
||||
- 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.
|
||||
|
||||
|
||||
+17
-5
@@ -9,14 +9,17 @@ Health check endpoints
|
||||
----------------------
|
||||
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``OPTIONS`` method instead of ``GET``.
|
||||
|
||||
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the leader:
|
||||
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the primary with leader lock:
|
||||
|
||||
- ``GET /``
|
||||
- ``GET /master``
|
||||
- ``GET /leader``
|
||||
- ``GET /primary``
|
||||
- ``GET /read-write``
|
||||
|
||||
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
|
||||
|
||||
- ``GET /leader``: returns HTTP status code **200** when the Patroni node has the leader lock. The major difference from the two previous endpoints is that it doesn't take into account whether PostgreSQL is running as the ``primary`` or the ``standby_leader``.
|
||||
|
||||
- ``GET /replica``: replica health check endpoint. It returns HTTP status code **200** only when the Patroni node is in the state ``running``, the role is ``replica`` and ``noloadbalance`` tag is not set.
|
||||
|
||||
- ``GET /replica?lag=<max-lag>``: replica check endpoint. In addition to checks from ``replica``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
|
||||
@@ -26,9 +29,18 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
||||
- ``GET /replica?lag=10MB``
|
||||
- ``GET /replica?lag=1GB``
|
||||
|
||||
- ``GET /read-only``: like the above endpoint, but also includes the primary.
|
||||
- ``GET /replica?tag_key1=value1&tag_key2=value2``: replica check endpoint. In addition, It will also check for user defined tags ``key1`` and ``key2`` and their respective values in the **tags** section of the yaml configuration management. If the tag isn't defined for an instance, or if the value in the yaml configuration doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
|
||||
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
|
||||
In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored.
|
||||
- ``GET /?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /master?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /leader?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /primary?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /read-write?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /standby_leader?tag_key1=value1&tag_key2=value2``
|
||||
- ``GET /standby-leader?tag_key1=value1&tag_key2=value2``
|
||||
|
||||
- ``GET /read-only``: like the above endpoint, but also includes the primary.
|
||||
|
||||
- ``GET /synchronous`` or ``GET /sync``: returns HTTP status code **200** only when the Patroni node is running as a synchronous standby.
|
||||
|
||||
@@ -45,7 +57,7 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
||||
|
||||
- ``GET /liveness``: always returns HTTP status code **200** what only indicates that Patroni is running. Could be used for ``livenessProbe``.
|
||||
|
||||
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubenetes endpoints for leader elections (OpenShift).
|
||||
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubernetes endpoints for leader elections (OpenShift).
|
||||
|
||||
Both, ``readiness`` and ``liveness`` endpoints are very light-weight and not executing any SQL. Probes should be configured in such a way that they start failing about time when the leader key is expiring. With the default value of ``ttl``, which is ``30s`` example probes would look like:
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ Group=postgres
|
||||
# Read in configuration file if it exists, otherwise proceed
|
||||
EnvironmentFile=-/etc/patroni_env.conf
|
||||
|
||||
WorkingDirectory=~
|
||||
# the default is the user's home directory, and if you want to change it, you must provide an absolute path.
|
||||
# WorkingDirectory=/home/sameuser
|
||||
|
||||
# Where to send early-startup messages from the server
|
||||
# This is normally controlled by the global default set by systemd
|
||||
|
||||
@@ -28,6 +28,25 @@ Feature: basic replication
|
||||
When I issue a GET request to http://127.0.0.1:8009/async
|
||||
Then I receive a response code 200
|
||||
|
||||
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 sleep for 2 seconds
|
||||
And I issue a GET request to http://127.0.0.1:8009/sync
|
||||
Then I receive a response code 200
|
||||
When I issue a GET request to http://127.0.0.1:8010/async
|
||||
Then I receive a response code 200
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import psycopg2
|
||||
import sys
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not (len(sys.argv) >= 3 and sys.argv[3] == "master"):
|
||||
sys.exit(1)
|
||||
|
||||
os.environ['PGPASSWORD'] = 'zalando'
|
||||
connection = psycopg2.connect(host='127.0.0.1', port=sys.argv[1], user='postgres')
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("SELECT slot_name FROM pg_replication_slots WHERE slot_type = 'logical'")
|
||||
|
||||
with open("data/postgres0/label", "w") as label:
|
||||
label.write(next(iter(cursor.fetchone()), ""))
|
||||
+32
-9
@@ -13,6 +13,8 @@ import threading
|
||||
import time
|
||||
import yaml
|
||||
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractController(object):
|
||||
@@ -176,7 +178,9 @@ class PatroniController(AbstractController):
|
||||
|
||||
config['name'] = name
|
||||
config['postgresql']['data_dir'] = self._data_dir
|
||||
config['postgresql']['basebackup'] = [{'checkpoint': 'fast'}]
|
||||
config['postgresql']['use_unix_socket'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
|
||||
config['postgresql']['use_unix_socket_repl'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
|
||||
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name)
|
||||
config['postgresql']['parameters'].update({
|
||||
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
|
||||
@@ -191,6 +195,8 @@ class PatroniController(AbstractController):
|
||||
if custom_config is not None:
|
||||
self.recursive_update(config, custom_config)
|
||||
|
||||
self.recursive_update(config, {
|
||||
'bootstrap': {'dcs': {'postgresql': {'parameters': {'wal_keep_segments': 100}}}}})
|
||||
if config['postgresql'].get('callbacks', {}).get('on_role_change'):
|
||||
config['postgresql']['callbacks']['on_role_change'] += ' ' + str(self.__PORT)
|
||||
|
||||
@@ -558,20 +564,39 @@ class ZooKeeperController(AbstractDcsController):
|
||||
return False
|
||||
|
||||
|
||||
class MockExhibitor(BaseHTTPRequestHandler):
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"servers":["127.0.0.1"],"port":2181}')
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
|
||||
class ExhibitorController(ZooKeeperController):
|
||||
|
||||
def __init__(self, context):
|
||||
super(ExhibitorController, self).__init__(context, False)
|
||||
os.environ.update({'PATRONI_EXHIBITOR_HOSTS': 'localhost', 'PATRONI_EXHIBITOR_PORT': '8181'})
|
||||
port = 8181
|
||||
exhibitor = HTTPServer(('', port), MockExhibitor)
|
||||
exhibitor.daemon_thread = True
|
||||
exhibitor_thread = threading.Thread(target=exhibitor.serve_forever)
|
||||
exhibitor_thread.daemon = True
|
||||
exhibitor_thread.start()
|
||||
os.environ.update({'PATRONI_EXHIBITOR_HOSTS': 'localhost', 'PATRONI_EXHIBITOR_PORT': str(port)})
|
||||
|
||||
|
||||
class RaftController(AbstractDcsController):
|
||||
|
||||
CONTROLLER_ADDR = 'localhost:1234'
|
||||
PASSWORD = '12345'
|
||||
|
||||
def __init__(self, context):
|
||||
super(RaftController, self).__init__(context)
|
||||
os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'", RAFT_PORT='1234')
|
||||
os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'",
|
||||
PATRONI_RAFT_PASSWORD=self.PASSWORD, RAFT_PORT='1234')
|
||||
self._raft = None
|
||||
|
||||
def _start(self):
|
||||
@@ -592,18 +617,16 @@ class RaftController(AbstractDcsController):
|
||||
|
||||
def cleanup_service_tree(self):
|
||||
from patroni.dcs.raft import KVStoreTTL
|
||||
from pysyncobj import SyncObjConf
|
||||
|
||||
if self._raft:
|
||||
self._raft.destroy()
|
||||
self._raft._SyncObj__thread.join()
|
||||
self.stop()
|
||||
os.makedirs(self._work_directory)
|
||||
self.start()
|
||||
|
||||
ready_event = threading.Event()
|
||||
conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, onReady=ready_event.set)
|
||||
self._raft = KVStoreTTL(None, [self.CONTROLLER_ADDR], conf)
|
||||
self._raft = KVStoreTTL(ready_event.set, None, None, partner_addrs=[self.CONTROLLER_ADDR], password=self.PASSWORD)
|
||||
self._raft.startAutoTick()
|
||||
ready_event.wait()
|
||||
|
||||
|
||||
@@ -846,11 +869,11 @@ class WatchdogMonitor(object):
|
||||
return triggered
|
||||
|
||||
|
||||
# actions to execute on start/stop of the tests and before running invidual features
|
||||
# actions to execute on start/stop of the tests and before running individual features
|
||||
def before_all(context):
|
||||
os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
|
||||
context.ci = 'TRAVIS_BUILD_NUMBER' in os.environ or 'BUILD_NUMBER' in os.environ
|
||||
context.timeout_multiplier = 2 if context.ci else 1
|
||||
context.ci = any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS'))
|
||||
context.timeout_multiplier = 5 if context.ci else 1 # MacOS sometimes is VERY slow
|
||||
context.pctl = PatroniPoolController(context)
|
||||
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
|
||||
context.dcs_ctl.start()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
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 {"loop_wait": 2, "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
|
||||
# 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 3 seconds
|
||||
# Make sure Patroni has finished telling Postgres it should be accepting writes.
|
||||
And postgres1 role is the primary after 20 seconds
|
||||
# 1. Create our test logical replication slot.
|
||||
# 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
|
||||
# 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
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
|
||||
When I start postgres0
|
||||
Then "members/postgres0" key in DCS has role=replica after 3 seconds
|
||||
And postgres0 role is the secondary after 20 seconds
|
||||
# Verify that the replica has advanced beyond the point in the WAL
|
||||
# where we created the replication slot so that on the next failover
|
||||
# cycle we don't accidentally rewind to before the slot creation.
|
||||
And replication works from postgres1 to postgres0 after 20 seconds
|
||||
When I shut down postgres1
|
||||
Then "members/postgres0" key in DCS has role=master after 3 seconds
|
||||
|
||||
# 2. After a failover the server (now a replica) still has the slot.
|
||||
When I start postgres1
|
||||
Then postgres1 role is the secondary after 20 seconds
|
||||
And "members/postgres1" key in DCS has role=replica after 3 seconds
|
||||
# give Patroni time to sync replication slots
|
||||
And I sleep for 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
And postgres1 does not have a logical replication slot named dummy_slot
|
||||
|
||||
# 3. After a failover the server (now a master) still has the slot.
|
||||
When I shut down postgres0
|
||||
Then "members/postgres1" key in DCS has role=master after 3 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
@@ -61,9 +61,15 @@ Scenario: check the scheduled restart
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Given I run patronictl.py pause batman
|
||||
Given I start postgres1
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I run patronictl.py pause batman
|
||||
Then I receive a response returncode 0
|
||||
When I kill postmaster on postgres1
|
||||
And I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 503
|
||||
When I run patronictl.py restart batman postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
When I start postgres1
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 200
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Feature: standby cluster
|
||||
Scenario: check permanent logical slots are preserved on failover/switchover
|
||||
Scenario: prepare the cluster with logical slots
|
||||
Given I start postgres1
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
@@ -10,15 +10,24 @@ Feature: standby cluster
|
||||
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 with callback configured
|
||||
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
|
||||
|
||||
@skip
|
||||
Scenario: check permanent logical slots are synced to the replica
|
||||
Given I run patronictl.py restart batman postgres1 --force
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
When I add the table replicate_me to postgres1
|
||||
And I get all changes from logical slot test_logical on postgres1
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
Scenario: Detach exiting node from the cluster
|
||||
When I shut down postgres1
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And "members/postgres0" key in DCS has role=master after 3 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/
|
||||
Then I receive a response code 200
|
||||
And there is a label with "test_logical" in postgres0 data directory
|
||||
|
||||
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
|
||||
@@ -35,6 +44,7 @@ Feature: standby cluster
|
||||
When I start postgres2 in a cluster batman1
|
||||
Then postgres2 role is the replica after 24 seconds
|
||||
And table foo is present on postgres2 after 20 seconds
|
||||
And postgres1 does not have a logical replication slot named test_logical
|
||||
|
||||
Scenario: check failover
|
||||
When I kill postgres1
|
||||
|
||||
@@ -33,6 +33,37 @@ def add_table(context, table_name, pg_name):
|
||||
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}')
|
||||
def toggle_wal_replay(context, action, pg_name):
|
||||
# pause or resume the wal replay process
|
||||
try:
|
||||
version = context.pctl.query(pg_name, "select pg_catalog.pg_read_file('PG_VERSION', 0, 2)").fetchone()
|
||||
wal = version and version[0] and int(version[0].split('.')[0]) < 10 and "xlog" or "wal"
|
||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal, 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}')
|
||||
def crdr_mytest(context, action, pg_name):
|
||||
try:
|
||||
if (action == "create"):
|
||||
context.pctl.query(pg_name, "create table if not exists mytest(id Numeric)")
|
||||
else:
|
||||
context.pctl.query(pg_name, "drop table if exists mytest")
|
||||
except pg.Error as e:
|
||||
assert False, "Error {0} table mytest on {1}: {2}".format(action, pg_name, e)
|
||||
|
||||
|
||||
@step('I load data on {pg_name:w}')
|
||||
def initiate_load(context, pg_name):
|
||||
# perform dummy load
|
||||
try:
|
||||
context.pctl.query(pg_name, "begin; insert into mytest select r::numeric from generate_series(1, 350000) r; commit;")
|
||||
except pg.Error as e:
|
||||
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')
|
||||
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
max_replication_delay *= context.timeout_multiplier
|
||||
|
||||
@@ -12,8 +12,10 @@ def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
|
||||
@then('There is a {label} with "{content}" in {name:w} data directory')
|
||||
def check_label(context, label, content, name):
|
||||
label = context.pctl.read_label(name, label)
|
||||
if label is None:
|
||||
label = ""
|
||||
label = label.replace('\n', '\\n')
|
||||
assert content in label, "{0} doesn't contain {1}".format(label, content)
|
||||
assert content in label, "\"{0}\" doesn't contain {1}".format(label, content)
|
||||
|
||||
|
||||
@step('I create label with "{content:w}" in {name:w} data directory')
|
||||
@@ -25,15 +27,18 @@ def write_label(context, content, name):
|
||||
def check_member(context, name, 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(name))
|
||||
if response.get(key) == value:
|
||||
dcs_value = response.get(key)
|
||||
if dcs_value == value:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "{0} does not have {1}={2} in dcs after {3} seconds".format(name, key, value, time_limit)
|
||||
assert False, "{0} does not have {1}={2} (found {3}) in dcs after {4} seconds".format(name, key, value,
|
||||
dcs_value, time_limit)
|
||||
|
||||
|
||||
@step('there is a non empty {key:w} key in DCS after {time_limit:d} seconds')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import time
|
||||
import psycopg2
|
||||
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@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):
|
||||
try:
|
||||
output = context.pctl.query(pg_name, ("SELECT pg_create_logical_replication_slot('{0}', '{1}'),"
|
||||
" current_database()").format(slot_name, plugin))
|
||||
print(output.fetchone())
|
||||
except psycopg2.Error as e:
|
||||
print(e)
|
||||
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
|
||||
|
||||
@then('{pg_name:w} has a logical replication slot named {slot_name} with the {plugin:w} plugin')
|
||||
def has_logical_replication_slot(context, pg_name, slot_name, plugin):
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
|
||||
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
|
||||
assert row, "Couldn't find replication slot named {0}".format(slot_name)
|
||||
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name)
|
||||
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
|
||||
"{1} rather than {2}").format(slot_name, row[1], plugin)
|
||||
except psycopg2.Error:
|
||||
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
|
||||
|
||||
@then('{pg_name:w} does not have a logical replication slot named {slot_name}')
|
||||
def does_not_have_logical_replication_slot(context, pg_name, slot_name):
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
|
||||
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
|
||||
assert not row, "Found unexpected replication slot named {0}".format(slot_name)
|
||||
except psycopg2.Error:
|
||||
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
|
||||
|
||||
|
||||
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
|
||||
slot1 = context.pctl.query(pg_name1, query).fetchone()
|
||||
slot2 = context.pctl.query(pg_name2, query).fetchone()
|
||||
if slot1[0] == slot2[0]:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
|
||||
|
||||
|
||||
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
|
||||
def logical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
|
||||
@@ -14,17 +14,6 @@ executable = sys.executable if os.name != 'nt' else sys.executable.replace('\\',
|
||||
callback = executable + " features/callback2.py "
|
||||
|
||||
|
||||
@step('I start {name:w} with callback configured')
|
||||
def start_patroni_with_callbacks(context, name):
|
||||
return context.pctl.start(name, custom_config={
|
||||
"postgresql": {
|
||||
"callbacks": {
|
||||
"on_role_change": executable + " features/callback.py"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@step('I start {name:w} in a cluster {cluster_name:w}')
|
||||
def start_patroni(context, name, cluster_name):
|
||||
return context.pctl.start(name, custom_config={
|
||||
@@ -55,7 +44,8 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
"port": port,
|
||||
"primary_slot_name": "pm_1",
|
||||
"create_replica_methods": ["backup_restore", "basebackup"]
|
||||
}
|
||||
},
|
||||
"postgresql": {"parameters": {"wal_level": "logical"}}
|
||||
}
|
||||
},
|
||||
"postgresql": {
|
||||
|
||||
@@ -20,6 +20,10 @@ metadata:
|
||||
spec:
|
||||
replicas: 3
|
||||
serviceName: *cluster_name
|
||||
selector:
|
||||
matchLabels:
|
||||
application: patroni
|
||||
cluster-name: *cluster_name
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
|
||||
@@ -74,6 +74,7 @@ class Patroni(AbstractPatroniDaemon):
|
||||
if local:
|
||||
self.tags = self.get_tags()
|
||||
self.request.reload_config(self.config)
|
||||
if local or sighup and self.api.reload_local_certificate():
|
||||
self.api.reload_config(self.config['restapi'])
|
||||
self.watchdog.reload_config(self.config)
|
||||
self.postgresql.reload_config(self.config['postgresql'], sighup)
|
||||
|
||||
+209
-26
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import psycopg2
|
||||
@@ -11,6 +12,7 @@ import six
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from ipaddress import ip_address, ip_network as _ip_network
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from six.moves.socketserver import ThreadingMixIn
|
||||
from six.moves.urllib_parse import urlparse, parse_qs
|
||||
@@ -24,6 +26,10 @@ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Ret
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ip_network(value):
|
||||
return _ip_network(value.decode('utf-8') if six.PY2 else value, False)
|
||||
|
||||
|
||||
class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _write_status_code_only(self, status_code):
|
||||
@@ -44,19 +50,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body.encode('utf-8'))
|
||||
|
||||
def _write_json_response(self, status_code, response):
|
||||
self._write_response(status_code, json.dumps(response), content_type='application/json')
|
||||
self._write_response(status_code, json.dumps(response, default=str), content_type='application/json')
|
||||
|
||||
def check_auth(func):
|
||||
"""Decorator function to check authorization header or client certificates
|
||||
def check_access(func):
|
||||
"""Decorator function to check the source ip, authorization header. or client certificates
|
||||
|
||||
Usage example:
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_PUT_foo():
|
||||
pass
|
||||
"""
|
||||
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self.server.check_auth(self):
|
||||
if self.server.check_access(self):
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -96,7 +102,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
patroni = self.server.patroni
|
||||
cluster = patroni.dcs.cluster
|
||||
|
||||
leader_optime = cluster and cluster.last_leader_operation or 0
|
||||
leader_optime = cluster and cluster.last_lsn or 0
|
||||
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:
|
||||
@@ -107,9 +113,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
response.get('role') == 'replica' and response.get('state') == 'running' else 503
|
||||
|
||||
if not cluster and patroni.ha.is_paused():
|
||||
leader_status_code = 200 if response.get('role') in ('master', 'standby_leader') else 503
|
||||
primary_status_code = 200 if response.get('role') == 'master' else 503
|
||||
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
|
||||
elif patroni.ha.is_leader():
|
||||
leader_status_code = 200
|
||||
if patroni.ha.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
|
||||
@@ -117,14 +125,20 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
primary_status_code = 200
|
||||
standby_leader_status_code = 503
|
||||
else:
|
||||
primary_status_code = standby_leader_status_code = 503
|
||||
leader_status_code = primary_status_code = standby_leader_status_code = 503
|
||||
|
||||
status_code = 503
|
||||
|
||||
ignore_tags = False
|
||||
if 'standby_leader' in path or 'standby-leader' in path:
|
||||
status_code = standby_leader_status_code
|
||||
elif 'master' in path or 'leader' in path or 'primary' in path or 'read-write' in path:
|
||||
ignore_tags = True
|
||||
elif 'leader' in path:
|
||||
status_code = leader_status_code
|
||||
ignore_tags = True
|
||||
elif 'master' in path or 'primary' in path or 'read-write' in path:
|
||||
status_code = primary_status_code
|
||||
ignore_tags = True
|
||||
elif 'replica' in path:
|
||||
status_code = replica_status_code
|
||||
elif 'read-only' in path:
|
||||
@@ -139,6 +153,25 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
elif path in ('/async', '/asynchronous') and not is_synchronous:
|
||||
status_code = replica_status_code
|
||||
|
||||
# check for user defined tags in query params
|
||||
if not ignore_tags and status_code == 200:
|
||||
qs_tag_prefix = "tag_"
|
||||
for qs_key, qs_value in self.path_query.items():
|
||||
if not qs_key.startswith(qs_tag_prefix):
|
||||
continue
|
||||
qs_key = qs_key[len(qs_tag_prefix):]
|
||||
qs_value = qs_value[0]
|
||||
instance_tag_value = patroni.tags.get(qs_key)
|
||||
# tag not registered for instance
|
||||
if instance_tag_value is None:
|
||||
status_code = 503
|
||||
break
|
||||
if not isinstance(instance_tag_value, six.string_types):
|
||||
instance_tag_value = str(instance_tag_value).lower()
|
||||
if instance_tag_value != qs_value:
|
||||
status_code = 503
|
||||
break
|
||||
|
||||
if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
|
||||
self._write_status_code_only(status_code)
|
||||
else:
|
||||
@@ -179,6 +212,84 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_error(502)
|
||||
|
||||
def do_GET_metrics(self):
|
||||
postgres = self.get_postgresql_status(True)
|
||||
patroni = self.server.patroni
|
||||
epoch = datetime.datetime(1970, 1, 1, tzinfo=tzutc)
|
||||
|
||||
metrics = []
|
||||
|
||||
scope_label = '{{scope="{0}"}}'.format(patroni.postgresql.scope)
|
||||
metrics.append("# HELP patroni_version Patroni semver without periods.")
|
||||
metrics.append("# TYPE patroni_version gauge")
|
||||
padded_semver = ''.join([x.zfill(2) for x in patroni.version.split('.')]) # 2.0.2 => 020002
|
||||
metrics.append("patroni_version{0} {1}".format(scope_label, padded_semver))
|
||||
|
||||
metrics.append("# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_running gauge")
|
||||
metrics.append("patroni_postgres_running{0} {1}".format(scope_label, int(postgres['state'] == 'running')))
|
||||
|
||||
metrics.append("# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.")
|
||||
metrics.append("# TYPE patroni_postmaster_start_time gauge")
|
||||
postmaster_start_time = postgres.get('postmaster_start_time')
|
||||
postmaster_start_time = (postmaster_start_time - epoch).total_seconds() if postmaster_start_time else 0
|
||||
metrics.append("patroni_postmaster_start_time{0} {1}".format(scope_label, postmaster_start_time))
|
||||
|
||||
metrics.append("# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_master gauge")
|
||||
metrics.append("patroni_master{0} {1}".format(scope_label, int(postgres['role'] == 'master')))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_location Current location of the Postgres"
|
||||
" transaction log, 0 if this node is not the leader.")
|
||||
metrics.append("# TYPE patroni_xlog_location counter")
|
||||
metrics.append("patroni_xlog_location{0} {1}".format(scope_label, postgres.get('xlog', {}).get('location', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_standby_leader gauge")
|
||||
metrics.append("patroni_standby_leader{0} {1}".format(scope_label, int(postgres['role'] == 'standby_leader')))
|
||||
|
||||
metrics.append("# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_replica gauge")
|
||||
metrics.append("patroni_replica{0} {1}".format(scope_label, int(postgres['role'] == 'replica')))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
|
||||
" Postgres transaction log, 0 if this node is not a replica.")
|
||||
metrics.append("# TYPE patroni_xlog_received_location counter")
|
||||
metrics.append("patroni_xlog_received_location{0} {1}"
|
||||
.format(scope_label, postgres.get('xlog', {}).get('received_location', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_replayed_location Current location of the replayed"
|
||||
" Postgres transaction log, 0 if this node is not a replica.")
|
||||
metrics.append("# TYPE patroni_xlog_replayed_location counter")
|
||||
metrics.append("patroni_xlog_replayed_location{0} {1}"
|
||||
.format(scope_label, postgres.get('xlog', {}).get('replayed_location', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed"
|
||||
" Postgres transaction log, 0 if null.")
|
||||
metrics.append("# TYPE patroni_xlog_replayed_timestamp gauge")
|
||||
replayed_timestamp = postgres.get('xlog', {}).get('replayed_timestamp')
|
||||
replayed_timestamp = (replayed_timestamp - epoch).total_seconds() if replayed_timestamp else 0
|
||||
metrics.append("patroni_xlog_replayed_timestamp{0} {1}".format(scope_label, replayed_timestamp))
|
||||
|
||||
metrics.append("# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_xlog_paused gauge")
|
||||
metrics.append("patroni_xlog_paused{0} {1}"
|
||||
.format(scope_label, int(postgres.get('xlog', {}).get('paused', False) is True)))
|
||||
|
||||
metrics.append("# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_server_version gauge")
|
||||
metrics.append("patroni_postgres_server_version {0} {1}".format(scope_label, postgres.get('server_version', 0)))
|
||||
|
||||
metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.")
|
||||
metrics.append("# TYPE patroni_cluster_unlocked gauge")
|
||||
metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres['cluster_unlocked'])))
|
||||
|
||||
metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_postgres_timeline counter")
|
||||
metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0)))
|
||||
|
||||
self._write_response(200, '\n'.join(metrics)+'\n', content_type='text/plain')
|
||||
|
||||
def _read_json_content(self, body_is_optional=False):
|
||||
if 'content-length' not in self.headers:
|
||||
return self.send_error(411) if not body_is_optional else {}
|
||||
@@ -193,11 +304,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
logger.exception('Bad request')
|
||||
self.send_error(400)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_PATCH_config(self):
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
cluster = self.server.patroni.dcs.get_cluster(True)
|
||||
if not (cluster.config and cluster.config.modify_index):
|
||||
return self.send_error(503)
|
||||
data = cluster.config.data.copy()
|
||||
@@ -208,7 +319,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.server.patroni.ha.wakeup()
|
||||
self._write_json_response(200, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_PUT_config(self):
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
@@ -219,7 +330,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
return self.send_error(502)
|
||||
self._write_json_response(200, request)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_reload(self):
|
||||
self.server.patroni.sighup_handler()
|
||||
self._write_response(202, 'reload scheduled')
|
||||
@@ -245,7 +356,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
status_code = 422
|
||||
return (status_code, error, scheduled_at)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_restart(self):
|
||||
status_code = 500
|
||||
data = 'restart failed'
|
||||
@@ -306,7 +417,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
status_code = 409
|
||||
self._write_response(status_code, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_DELETE_restart(self):
|
||||
if self.server.patroni.ha.delete_future_restart():
|
||||
data = "scheduled restart deleted"
|
||||
@@ -316,7 +427,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
code = 404
|
||||
self._write_response(code, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_DELETE_switchover(self):
|
||||
failover = self.server.patroni.dcs.get_cluster().failover
|
||||
if failover and failover.scheduled_at:
|
||||
@@ -330,7 +441,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
code = 404
|
||||
self._write_response(code, data)
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_reinitialize(self):
|
||||
request = self._read_json_content(body_is_optional=True)
|
||||
|
||||
@@ -362,7 +473,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if not cluster.failover:
|
||||
return 503, action.title() + ' failed'
|
||||
except Exception as e:
|
||||
logger.debug('Exception occured during polling %s result: %s', action, e)
|
||||
logger.debug('Exception occurred during polling %s result: %s', action, e)
|
||||
return 503, action.title() + ' status unknown'
|
||||
|
||||
def is_failover_possible(self, cluster, leader, candidate, action):
|
||||
@@ -387,7 +498,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
return None
|
||||
return action + ' is not possible: no good candidates have been found'
|
||||
|
||||
@check_auth
|
||||
@check_access
|
||||
def do_POST_failover(self, action='failover'):
|
||||
request = self._read_json_content()
|
||||
(status_code, data) = (400, '')
|
||||
@@ -475,7 +586,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if postgresql.state not in ('running', 'restarting', 'starting'):
|
||||
raise RetryFailedError('')
|
||||
stmt = ("SELECT " + postgresql.POSTMASTER_START_TIME + ", " + postgresql.TL_LSN + ","
|
||||
" pg_catalog.to_char(pg_catalog.pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
|
||||
" pg_catalog.pg_last_xact_replay_timestamp(),"
|
||||
" pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
|
||||
"FROM (SELECT (SELECT rolname FROM pg_authid WHERE oid = usesysid) AS usename,"
|
||||
" application_name, client_addr, w.state, sync_state, sync_priority"
|
||||
@@ -535,7 +646,8 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
self.patroni = patroni
|
||||
self.__listen = None
|
||||
self.__ssl_options = None
|
||||
self.http_extra_headers = {}
|
||||
self.__ssl_serial_number = None
|
||||
self._received_new_cert = False
|
||||
self.reload_config(config)
|
||||
self.daemon = True
|
||||
|
||||
@@ -558,7 +670,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
|
||||
|
||||
def check_basic_auth_key(self, key):
|
||||
return self.__auth_key == key
|
||||
return hmac.compare_digest(self.__auth_key, key.encode('utf-8'))
|
||||
|
||||
def check_auth_header(self, auth_header):
|
||||
if self.__auth_key:
|
||||
@@ -567,7 +679,35 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
|
||||
return 'not authenticated'
|
||||
|
||||
def check_auth(self, rh):
|
||||
@staticmethod
|
||||
def __resolve_ips(host, port):
|
||||
try:
|
||||
for _, _, _, _, sa in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP):
|
||||
yield ip_network(sa[0])
|
||||
except Exception as e:
|
||||
logger.error('Failed to resolve %s: %r', host, e)
|
||||
|
||||
def __members_ips(self):
|
||||
cluster = self.patroni.dcs.cluster
|
||||
if self.__allowlist_include_members and cluster:
|
||||
for member in cluster.members:
|
||||
if member.api_url:
|
||||
try:
|
||||
r = urlparse(member.api_url)
|
||||
host = r.hostname
|
||||
port = r.port or (443 if r.scheme == 'https' else 80)
|
||||
for ip in self.__resolve_ips(host, port):
|
||||
yield ip
|
||||
except Exception as e:
|
||||
logger.debug('Failed to parse url %s: %r', member.api_url, e)
|
||||
|
||||
def check_access(self, rh):
|
||||
if self.__allowlist or self.__allowlist_include_members:
|
||||
incoming_ip = rh.client_address[0]
|
||||
incoming_ip = ip_address(incoming_ip.decode('utf-8') if six.PY2 else incoming_ip)
|
||||
if not any(incoming_ip in net for net in self.__allowlist + tuple(self.__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
|
||||
if self.__protocol == 'https' and self.__ssl_options.get('verify_client') in ('required', 'optional'):
|
||||
return rh._write_response(403, 'client certificate required')
|
||||
@@ -623,6 +763,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
|
||||
self.__listen = listen
|
||||
self.__ssl_options = ssl_options
|
||||
self._received_new_cert = False # reset to False after reload_config()
|
||||
|
||||
self.__httpserver_init(host, port)
|
||||
Thread.__init__(self, target=self.serve_forever)
|
||||
@@ -634,7 +775,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
if self.__protocol == 'https':
|
||||
import ssl
|
||||
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=ssl_options.get('cafile'))
|
||||
ctx.load_cert_chain(certfile=ssl_options['certfile'], keyfile=ssl_options.get('keyfile'))
|
||||
if ssl_options.get('ciphers'):
|
||||
ctx.set_ciphers(ssl_options['ciphers'])
|
||||
ctx.load_cert_chain(certfile=ssl_options['certfile'], keyfile=ssl_options.get('keyfile'),
|
||||
password=ssl_options.get('keyfile_password'))
|
||||
verify_client = ssl_options.get('verify_client')
|
||||
if verify_client:
|
||||
modes = {'none': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, 'required': ssl.CERT_REQUIRED}
|
||||
@@ -642,6 +786,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
ctx.verify_mode = modes[verify_client]
|
||||
else:
|
||||
logger.error('Bad value in the "restapi.verify_client": %s', verify_client)
|
||||
self.__ssl_serial_number = self.get_certificate_serial_number()
|
||||
self.socket = ctx.wrap_socket(self.socket, server_side=True)
|
||||
if reloading_config:
|
||||
self.start()
|
||||
@@ -664,11 +809,49 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
newsock = (sock, newsock)
|
||||
return newsock, addr
|
||||
|
||||
def shutdown_request(self, request):
|
||||
if isinstance(request, tuple):
|
||||
_, request = request # SSLSocket
|
||||
return super(RestApiServer, self).shutdown_request(request)
|
||||
|
||||
def get_certificate_serial_number(self):
|
||||
if self.__ssl_options.get('certfile'):
|
||||
import ssl
|
||||
try:
|
||||
crt = ssl._ssl._test_decode_cert(self.__ssl_options['certfile'])
|
||||
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)
|
||||
|
||||
def reload_local_certificate(self):
|
||||
if self.__protocol == 'https':
|
||||
on_disk_cert_serial_number = self.get_certificate_serial_number()
|
||||
if on_disk_cert_serial_number != self.__ssl_serial_number:
|
||||
self._received_new_cert = True
|
||||
self.__ssl_serial_number = on_disk_cert_serial_number
|
||||
return True
|
||||
|
||||
def _build_allowlist(self, value):
|
||||
if isinstance(value, list):
|
||||
for v in value:
|
||||
if '/' in v: # netmask
|
||||
try:
|
||||
yield ip_network(v)
|
||||
except Exception as e:
|
||||
logger.error('Invalid value "%s" in the allowlist: %r', v, e)
|
||||
else: # ip or hostname, try to resolve it
|
||||
for ip in self.__resolve_ips(v, 8080):
|
||||
yield ip
|
||||
|
||||
def reload_config(self, config):
|
||||
if 'listen' not in config: # changing config in runtime
|
||||
raise ValueError('Can not find "restapi.listen" config')
|
||||
|
||||
ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'cafile') if n in config}
|
||||
self.__allowlist = tuple(self._build_allowlist(config.get('allowlist')))
|
||||
self.__allowlist_include_members = config.get('allowlist_include_members')
|
||||
|
||||
ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'keyfile_password',
|
||||
'cafile', 'ciphers') if n in config}
|
||||
|
||||
self.http_extra_headers = config.get('http_extra_headers') or {}
|
||||
self.http_extra_headers.update((config.get('https_extra_headers') or {}) if ssl_options.get('certfile') else {})
|
||||
@@ -676,10 +859,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
if isinstance(config.get('verify_client'), six.string_types):
|
||||
ssl_options['verify_client'] = config['verify_client'].lower()
|
||||
|
||||
if self.__listen != config['listen'] or self.__ssl_options != ssl_options:
|
||||
if self.__listen != config['listen'] or self.__ssl_options != ssl_options or self._received_new_cert:
|
||||
self.__initialize(config['listen'], ssl_options)
|
||||
|
||||
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
|
||||
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')) if 'auth' in config else None
|
||||
self.connection_string = uri(self.__protocol, config.get('connect_address') or self.__listen, 'patroni')
|
||||
|
||||
@staticmethod
|
||||
|
||||
+47
-23
@@ -21,6 +21,7 @@ _AUTH_ALLOWED_PARAMETERS = (
|
||||
'sslmode',
|
||||
'sslcert',
|
||||
'sslkey',
|
||||
'sslpassword',
|
||||
'sslrootcert',
|
||||
'sslcrl',
|
||||
'gssencmode',
|
||||
@@ -59,6 +60,7 @@ class Config(object):
|
||||
__DEFAULT_CONFIG = {
|
||||
'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10,
|
||||
'maximum_lag_on_failover': 1048576,
|
||||
'maximum_lag_on_syncnode': -1,
|
||||
'check_timeline': False,
|
||||
'master_start_timeout': 300,
|
||||
'master_stop_timeout': 0,
|
||||
@@ -230,7 +232,7 @@ class Config(object):
|
||||
for name, value in (value or {}).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 overriden from DCS
|
||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
|
||||
if name in ('synchronous_mode', 'synchronous_mode_strict'):
|
||||
config[name] = value
|
||||
else:
|
||||
@@ -264,12 +266,44 @@ class Config(object):
|
||||
if value:
|
||||
ret[section][param] = value
|
||||
|
||||
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'cafile', 'verify_client',
|
||||
'http_extra_headers', 'https_extra_headers'])
|
||||
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
|
||||
'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
|
||||
'https_extra_headers', 'allowlist', 'allowlist_include_members'])
|
||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_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('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
|
||||
|
||||
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_bool(value)
|
||||
if value is not None:
|
||||
ret[first][second] = value
|
||||
|
||||
for second in ('max_queue_size', 'file_size', 'file_num'):
|
||||
value = ret.get('log', {}).pop(second, None)
|
||||
if value:
|
||||
value = parse_int(value)
|
||||
if value is not None:
|
||||
ret['log'][second] = value
|
||||
|
||||
def _parse_list(value):
|
||||
if not (value.strip().startswith('-') or '[' in value):
|
||||
value = '[{0}]'.format(value)
|
||||
try:
|
||||
return yaml.safe_load(value)
|
||||
except Exception:
|
||||
logger.exception('Exception when parsing list %s', value)
|
||||
return None
|
||||
|
||||
for first, second in (('raft', 'partner_addrs'), ('restapi', 'allowlist')):
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = _parse_list(value)
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
def _parse_dict(value):
|
||||
if not value.strip().startswith('{'):
|
||||
@@ -280,11 +314,13 @@ class Config(object):
|
||||
logger.exception('Exception when parsing dict %s', value)
|
||||
return None
|
||||
|
||||
value = ret.get('log', {}).pop('loggers', None)
|
||||
if value:
|
||||
value = _parse_dict(value)
|
||||
if value:
|
||||
ret['log']['loggers'] = value
|
||||
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
|
||||
for second in params:
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
value = _parse_dict(value)
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
def _get_auth(name, params=None):
|
||||
ret = {}
|
||||
@@ -307,24 +343,11 @@ class Config(object):
|
||||
if authentication:
|
||||
ret['postgresql']['authentication'] = authentication
|
||||
|
||||
def _parse_list(value):
|
||||
if not (value.strip().startswith('-') or '[' in value):
|
||||
value = '[{0}]'.format(value)
|
||||
try:
|
||||
return yaml.safe_load(value)
|
||||
except Exception:
|
||||
logger.exception('Exception when parsing list %s', value)
|
||||
return None
|
||||
|
||||
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs'])
|
||||
if 'raft' in ret and 'partner_addrs' in ret['raft']:
|
||||
ret['raft']['partner_addrs'] = _parse_list(ret['raft']['partner_addrs'])
|
||||
|
||||
for param in list(os.environ.keys()):
|
||||
if param.startswith(PATRONI_ENV_PREFIX):
|
||||
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
|
||||
name, suffix = (param[8:].split('_', 1) + [''])[:2]
|
||||
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'URL', 'PROXY',
|
||||
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'SRV_SUFFIX', 'URL', 'PROXY',
|
||||
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
|
||||
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT',
|
||||
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS',
|
||||
@@ -336,7 +359,7 @@ class Config(object):
|
||||
value = value and _parse_list(value)
|
||||
elif suffix == 'LABELS':
|
||||
value = _parse_dict(value)
|
||||
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'BYPASS_API_SERVICE'):
|
||||
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
|
||||
value = parse_bool(value)
|
||||
if value:
|
||||
ret[name.lower()][suffix.lower()] = value
|
||||
@@ -411,6 +434,7 @@ class Config(object):
|
||||
'synchronous_mode',
|
||||
'synchronous_mode_strict',
|
||||
'synchronous_node_count',
|
||||
'maximum_lag_on_syncnode'
|
||||
)
|
||||
|
||||
pg_config.update({p: config[p] for p in updated_fields if p in config})
|
||||
|
||||
+27
-30
@@ -7,7 +7,6 @@ import codecs
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
import dateutil.tz
|
||||
import cdiff
|
||||
import copy
|
||||
import difflib
|
||||
import io
|
||||
@@ -25,16 +24,22 @@ import yaml
|
||||
from click import ClickException
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from patroni.dcs import get_dcs as _get_dcs
|
||||
from patroni.exceptions import PatroniException
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.misc import postgres_version_to_int
|
||||
from patroni.utils import cluster_as_json, patch_config, polling_loop
|
||||
from patroni.request import PatroniRequest
|
||||
from patroni.version import __version__
|
||||
from prettytable import ALL, FRAME, PrettyTable
|
||||
from six.moves.urllib_parse import urlparse
|
||||
|
||||
try:
|
||||
from ydiff import markup_to_pager, PatchStream
|
||||
except ImportError: # pragma: no cover
|
||||
from cdiff import markup_to_pager, PatchStream
|
||||
|
||||
from .dcs import get_dcs as _get_dcs
|
||||
from .exceptions import PatroniException
|
||||
from .postgresql import Postgresql
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .utils import cluster_as_json, find_executable, patch_config, polling_loop
|
||||
from .request import PatroniRequest
|
||||
from .version import __version__
|
||||
|
||||
CONFIG_DIR_PATH = click.get_app_dir('patroni')
|
||||
CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml')
|
||||
DCS_DEFAULTS = {'zookeeper': {'port': 2181, 'template': "zookeeper:\n hosts: ['{host}:{port}']"},
|
||||
@@ -1086,7 +1091,14 @@ def show_diff(before_editing, after_editing):
|
||||
side_by_side = False
|
||||
width = 80
|
||||
tab_width = 8
|
||||
cdiff.markup_to_pager(cdiff.PatchStream(buf), opts)
|
||||
wrap = True
|
||||
if find_executable('less'):
|
||||
pager = None
|
||||
else:
|
||||
pager = 'more.com' if sys.platform == 'win32' else 'more'
|
||||
pager_options = None
|
||||
|
||||
markup_to_pager(PatchStream(buf), opts)
|
||||
else:
|
||||
for line in unified_diff:
|
||||
click.echo(line.rstrip('\n'))
|
||||
@@ -1161,24 +1173,6 @@ def apply_yaml_file(data, filename):
|
||||
return format_config_for_editing(changed_data), changed_data
|
||||
|
||||
|
||||
def find_executable(executable, path=None):
|
||||
_, ext = os.path.splitext(executable)
|
||||
|
||||
if (sys.platform == 'win32') and (ext != '.exe'):
|
||||
executable = executable + '.exe'
|
||||
|
||||
if os.path.isfile(executable):
|
||||
return executable
|
||||
|
||||
if path is None:
|
||||
path = os.environ.get('PATH', os.defpath)
|
||||
|
||||
for p in path.split(os.pathsep):
|
||||
f = os.path.join(p, executable)
|
||||
if os.path.isfile(f):
|
||||
return f
|
||||
|
||||
|
||||
def invoke_editor(before_editing, cluster_name):
|
||||
"""Starts editor command to edit configuration in human readable format
|
||||
|
||||
@@ -1305,10 +1299,13 @@ def version(obj, cluster_name, member_names):
|
||||
def history(obj, cluster_name, fmt):
|
||||
cluster = get_dcs(obj, cluster_name).get_cluster()
|
||||
history = cluster.history and cluster.history.lines or []
|
||||
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
|
||||
for line in history:
|
||||
if len(line) < 4:
|
||||
line.append('')
|
||||
print_output(['TL', 'LSN', 'Reason', 'Timestamp'], history, {'TL': 'r', 'LSN': 'r'}, fmt)
|
||||
if len(line) < len(table_header_row):
|
||||
add_coloumn_num = len(table_header_row) - len(line)
|
||||
for _ in range(add_coloumn_num):
|
||||
line.append('')
|
||||
print_output(table_header_row, history, {'TL': 'r', 'LSN': 'r'}, fmt)
|
||||
|
||||
|
||||
def format_pg_version(version):
|
||||
|
||||
+142
-40
@@ -13,12 +13,13 @@ import time
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
from copy import deepcopy
|
||||
from patroni.exceptions import PatroniFatalException
|
||||
from patroni.utils import parse_bool, uri
|
||||
from random import randint
|
||||
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
||||
from threading import Event, Lock
|
||||
|
||||
from ..exceptions import PatroniFatalException
|
||||
from ..utils import deep_compare, parse_bool, uri
|
||||
|
||||
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,7 +68,11 @@ def dcs_modules():
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
toc = set()
|
||||
for importer in pkgutil.iter_importers(dcs_dirname):
|
||||
# dcs_dirname may contain a dot, which causes pkgutil.iter_importers()
|
||||
# to misinterpret the path as a package name. This can be avoided
|
||||
# altogether by not passing a path at all, because PyInstaller's
|
||||
# FrozenImporter is a singleton and registered as top-level finder.
|
||||
for importer in pkgutil.iter_importers():
|
||||
if hasattr(importer, 'toc'):
|
||||
toc |= importer.toc
|
||||
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2]
|
||||
@@ -133,6 +138,8 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
else:
|
||||
try:
|
||||
data = json.loads(data)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (TypeError, ValueError):
|
||||
data = {}
|
||||
return Member(index, name, session, data)
|
||||
@@ -206,6 +213,15 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
def is_running(self):
|
||||
return self.state == 'running'
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
version = self.data.get('version')
|
||||
if version:
|
||||
try:
|
||||
return tuple(map(int, version.split('.')))
|
||||
except Exception:
|
||||
logger.debug('Failed to parse Patroni version %s', version)
|
||||
|
||||
|
||||
class RemoteMember(Member):
|
||||
""" Represents a remote master for a standby cluster
|
||||
@@ -259,14 +275,10 @@ class Leader(namedtuple('Leader', 'index,session,member')):
|
||||
"""
|
||||
>>> Leader(1, '', Member.from_node(1, '', '', '{"version":"z"}')).checkpoint_after_promote
|
||||
"""
|
||||
version = self.data.get('version')
|
||||
if version:
|
||||
try:
|
||||
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
|
||||
if tuple(map(int, version.split('.'))) > (1, 5, 6):
|
||||
return self.data['role'] == 'master' and 'checkpoint_after_promote' not in self.data
|
||||
except Exception:
|
||||
logger.debug('Failed to parse Patroni version %s', version)
|
||||
version = self.member.version
|
||||
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
|
||||
if version and version > (1, 5, 6):
|
||||
return self.data.get('role') == 'master' and 'checkpoint_after_promote' not in self.data
|
||||
|
||||
|
||||
class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')):
|
||||
@@ -341,6 +353,10 @@ class ClusterConfig(namedtuple('ClusterConfig', 'index,data,modify_index')):
|
||||
self.data.get('permanent_slots') or self.data.get('slots')
|
||||
) or {}
|
||||
|
||||
@property
|
||||
def ignore_slots_matchers(self):
|
||||
return isinstance(self.data, dict) and self.data.get('ignore_slots') or []
|
||||
|
||||
@property
|
||||
def max_timelines_history(self):
|
||||
return self.data.get('max_timelines_history', 0)
|
||||
@@ -428,19 +444,20 @@ class TimelineHistory(namedtuple('TimelineHistory', 'index,value,lines')):
|
||||
return TimelineHistory(index, value, lines)
|
||||
|
||||
|
||||
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operation,members,failover,sync,history')):
|
||||
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,failover,sync,history,slots')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
|
||||
Consists of the following fields:
|
||||
:param initialize: shows whether this cluster has initialization key stored in DC or not.
|
||||
:param config: global dynamic configuration, reference to `ClusterConfig` object
|
||||
:param leader: `Leader` object which represents current leader of the cluster
|
||||
:param last_leader_operation: int or long object containing position of last known leader operation.
|
||||
This value is stored in `/optime/leader` key
|
||||
:param last_lsn: int or long object containing position of last known leader LSN.
|
||||
This value is stored in the `/status` key or `/optime/leader` (legacy) key
|
||||
:param members: list of Member object, all PostgreSQL cluster members including leader
|
||||
:param failover: reference to `Failover` object
|
||||
:param sync: reference to `SyncState` object, last observed synchronous replication state.
|
||||
:param history: reference to `TimelineHistory` object
|
||||
:param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}
|
||||
"""
|
||||
|
||||
def is_unlocked(self):
|
||||
@@ -466,22 +483,41 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
def is_synchronous_mode(self):
|
||||
return self.check_mode('synchronous_mode')
|
||||
|
||||
def get_replication_slots(self, my_name, role):
|
||||
@property
|
||||
def __permanent_slots(self):
|
||||
return self.config and self.config.permanent_slots or {}
|
||||
|
||||
@property
|
||||
def __permanent_physical_slots(self):
|
||||
return {name: value for name, value in self.__permanent_slots.items()
|
||||
if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'}
|
||||
|
||||
@property
|
||||
def __permanent_logical_slots(self):
|
||||
return {name: value for name, value in self.__permanent_slots.items() if isinstance(value, dict)
|
||||
and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')}
|
||||
|
||||
@property
|
||||
def use_slots(self):
|
||||
return self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
|
||||
|
||||
def get_replication_slots(self, my_name, role, nofailover, major_version, show_error=False):
|
||||
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
|
||||
# the current master, because that member would replicate from elsewhere. We still create the slot if
|
||||
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
|
||||
# master), or if replicatefrom destination member happens to be the current master
|
||||
use_slots = self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
|
||||
use_slots = self.use_slots
|
||||
if role in ('master', 'standby_leader'):
|
||||
slot_members = [m.name for m in self.members if use_slots and m.name != my_name and
|
||||
(m.replicatefrom is None or m.replicatefrom == my_name or
|
||||
not self.has_member(m.replicatefrom))]
|
||||
permanent_slots = (self.config and self.config.permanent_slots or {}).copy()
|
||||
permanent_slots = self.__permanent_slots if use_slots and \
|
||||
role == 'master' else self.__permanent_physical_slots
|
||||
else:
|
||||
# only manage slots for replicas that replicate from this one, except for the leader among them
|
||||
slot_members = [m.name for m in self.members if use_slots and
|
||||
m.replicatefrom == my_name and m.name != self.leader.name]
|
||||
permanent_slots = {}
|
||||
permanent_slots = self.__permanent_logical_slots if use_slots and not nofailover else {}
|
||||
|
||||
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
|
||||
|
||||
@@ -495,6 +531,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
for k, v in slot_conflicts.items() if len(v) > 1))
|
||||
|
||||
# "merge" replication slots for members with permanent_replication_slots
|
||||
disabled_permanent_logical_slots = []
|
||||
for name, value in permanent_slots.items():
|
||||
if not slot_name_re.match(name):
|
||||
logger.error("Invalid permanent replication slot name '%s'", name)
|
||||
@@ -512,7 +549,9 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
slots[name] = value
|
||||
continue
|
||||
elif value['type'] == 'logical' and value.get('database') and value.get('plugin'):
|
||||
if name in slots:
|
||||
if major_version < 110000:
|
||||
disabled_permanent_logical_slots.append(name)
|
||||
elif name in slots:
|
||||
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with" +
|
||||
" physical replication slot for cluster member", name, value)
|
||||
else:
|
||||
@@ -521,20 +560,53 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
|
||||
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
|
||||
|
||||
if disabled_permanent_logical_slots and show_error:
|
||||
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
|
||||
"Following slots will not be created: %s.", disabled_permanent_logical_slots)
|
||||
|
||||
return slots
|
||||
|
||||
def has_permanent_logical_slots(self, name):
|
||||
slots = self.get_replication_slots(name, 'master').values()
|
||||
def has_permanent_logical_slots(self, my_name, nofailover, major_version=110000):
|
||||
if major_version < 110000:
|
||||
return False
|
||||
slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).values()
|
||||
return any(v for v in slots if v.get("type") == "logical")
|
||||
|
||||
def should_enforce_hot_standby_feedback(self, my_name, nofailover, major_version):
|
||||
"""
|
||||
The hot_standby_feedback must be enabled if the current replica has logical slots
|
||||
or it is working as a cascading replica for the other node that has logical slots.
|
||||
"""
|
||||
|
||||
if major_version < 110000:
|
||||
return False
|
||||
|
||||
if self.has_permanent_logical_slots(my_name, nofailover, major_version):
|
||||
return True
|
||||
|
||||
if self.use_slots:
|
||||
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader.name]
|
||||
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
|
||||
return False
|
||||
|
||||
def get_my_slot_name_on_primary(self, my_name, replicatefrom):
|
||||
"""
|
||||
P <-- I <-- L
|
||||
In case of cascading replication we have to check not our physical slot,
|
||||
but slot of the replica that connects us to the primary.
|
||||
"""
|
||||
|
||||
m = self.get_member(replicatefrom, False) if replicatefrom else None
|
||||
return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) if m else slot_name_from_member_name(my_name)
|
||||
|
||||
@property
|
||||
def timeline(self):
|
||||
"""
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0).timeline
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0).timeline
|
||||
0
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]')).timeline
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0).timeline
|
||||
1
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]')).timeline
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0).timeline
|
||||
0
|
||||
"""
|
||||
if self.history:
|
||||
@@ -547,6 +619,10 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
||||
return 1
|
||||
return 0
|
||||
|
||||
@property
|
||||
def min_version(self):
|
||||
return next(iter(sorted(filter(lambda v: v, [m.version for m in self.members])) + [None]))
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractDCS(object):
|
||||
@@ -558,7 +634,8 @@ class AbstractDCS(object):
|
||||
_HISTORY = 'history'
|
||||
_MEMBERS = 'members/'
|
||||
_OPTIME = 'optime'
|
||||
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
|
||||
_STATUS = 'status' # JSON, contains "leader_lsn" and confirmed_flush_lsn of logical "slots" on the leader
|
||||
_LEADER_OPTIME = _OPTIME + '/' + _LEADER # legacy
|
||||
_SYNC = 'sync'
|
||||
|
||||
def __init__(self, config):
|
||||
@@ -574,7 +651,8 @@ class AbstractDCS(object):
|
||||
self._cluster = None
|
||||
self._cluster_valid_till = 0
|
||||
self._cluster_thread_lock = Lock()
|
||||
self._last_leader_operation = ''
|
||||
self._last_lsn = ''
|
||||
self._last_status = {}
|
||||
self.event = Event()
|
||||
|
||||
def client_path(self, path):
|
||||
@@ -608,6 +686,10 @@ class AbstractDCS(object):
|
||||
def history_path(self):
|
||||
return self.client_path(self._HISTORY)
|
||||
|
||||
@property
|
||||
def status_path(self):
|
||||
return self.client_path(self._STATUS)
|
||||
|
||||
@property
|
||||
def leader_optime_path(self):
|
||||
return self.client_path(self._LEADER_OPTIME)
|
||||
@@ -678,14 +760,30 @@ class AbstractDCS(object):
|
||||
self._cluster_valid_till = 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_leader_optime(self, last_operation):
|
||||
"""write current xlog location into `/optime/leader` key in DCS
|
||||
:param last_operation: absolute xlog location in bytes
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
"""write current WAL LSN into `/optime/leader` key in DCS
|
||||
|
||||
:param last_lsn: absolute WAL LSN in bytes
|
||||
:returns: `!True` on success."""
|
||||
|
||||
def write_leader_optime(self, last_operation):
|
||||
if self._last_leader_operation != last_operation and self._write_leader_optime(last_operation):
|
||||
self._last_leader_operation = last_operation
|
||||
def write_leader_optime(self, last_lsn):
|
||||
if self._last_lsn != last_lsn and self._write_leader_optime(last_lsn):
|
||||
self._last_lsn = last_lsn
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_status(self, value):
|
||||
"""write current WAL LSN and confirmed_flush_lsn of permanent slots into the `/status` key in DCS
|
||||
|
||||
:param value: status serialized in JSON forman
|
||||
:returns: `!True` on success."""
|
||||
|
||||
def write_status(self, value):
|
||||
if not deep_compare(self._last_status, value) and self._write_status(json.dumps(value, separators=(',', ':'))):
|
||||
self._last_status = value
|
||||
cluster = self.cluster
|
||||
min_version = cluster and cluster.min_version
|
||||
if min_version and min_version < (2, 1, 0):
|
||||
self._write_leader_optime(str(value[self._OPTIME]))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _update_leader(self):
|
||||
@@ -697,16 +795,20 @@ class AbstractDCS(object):
|
||||
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
||||
for example for etcd `prevValue` parameter must be used."""
|
||||
|
||||
def update_leader(self, last_operation, access_is_restricted=False):
|
||||
def update_leader(self, last_lsn, slots=None):
|
||||
"""Update leader key (or session) ttl and optime/leader
|
||||
|
||||
:param last_operation: absolute xlog location in bytes
|
||||
:param last_lsn: absolute WAL LSN in bytes
|
||||
:param slots: dict with permanent slots confirmed_flush_lsn
|
||||
:returns: `!True` if leader key (or session) has been updated successfully.
|
||||
If not, `!False` must be returned and current instance would be demoted."""
|
||||
|
||||
ret = self._update_leader()
|
||||
if ret and last_operation:
|
||||
self.write_leader_optime(last_operation)
|
||||
if ret and last_lsn:
|
||||
status = {self._OPTIME: last_lsn}
|
||||
if slots:
|
||||
status['slots'] = slots
|
||||
self.write_status(status)
|
||||
return ret
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -775,13 +877,13 @@ class AbstractDCS(object):
|
||||
"""Remove leader key from DCS.
|
||||
This method should remove leader key if current instance is the leader"""
|
||||
|
||||
def delete_leader(self, last_operation=None):
|
||||
def delete_leader(self, last_lsn=None):
|
||||
"""Update optime/leader and voluntarily remove leader key from DCS.
|
||||
This method should remove leader key if current instance is the leader.
|
||||
:param last_operation: latest checkpoint location in bytes"""
|
||||
:param last_lsn: latest checkpoint location in bytes"""
|
||||
|
||||
if last_operation:
|
||||
self.write_leader_optime(last_operation)
|
||||
if last_lsn:
|
||||
self.write_leader_optime(last_lsn)
|
||||
return self._delete_leader()
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
+82
-27
@@ -8,6 +8,7 @@ import ssl
|
||||
import time
|
||||
import urllib3
|
||||
|
||||
from collections import namedtuple
|
||||
from consul import ConsulException, NotFound, base
|
||||
from urllib3.exceptions import HTTPError
|
||||
from six.moves.urllib.parse import urlencode, urlparse, quote
|
||||
@@ -36,6 +37,9 @@ class InvalidSession(ConsulException):
|
||||
"""invalid session"""
|
||||
|
||||
|
||||
Response = namedtuple('Response', 'code,headers,body,content')
|
||||
|
||||
|
||||
class HTTPClient(object):
|
||||
|
||||
def __init__(self, host='127.0.0.1', port=8500, token=None, scheme='http', verify=True, cert=None, ca_cert=None):
|
||||
@@ -71,16 +75,17 @@ class HTTPClient(object):
|
||||
|
||||
@staticmethod
|
||||
def response(response):
|
||||
data = response.data.decode('utf-8')
|
||||
content = response.data
|
||||
body = content.decode('utf-8')
|
||||
if response.status == 500:
|
||||
msg = '{0} {1}'.format(response.status, data)
|
||||
if data.startswith('Invalid Session TTL'):
|
||||
msg = '{0} {1}'.format(response.status, body)
|
||||
if body.startswith('Invalid Session TTL'):
|
||||
raise InvalidSessionTTL(msg)
|
||||
elif data.startswith('invalid session'):
|
||||
elif body.startswith('invalid session'):
|
||||
raise InvalidSession(msg)
|
||||
else:
|
||||
raise ConsulInternalError(msg)
|
||||
return base.Response(response.status, response.headers, data)
|
||||
return Response(response.status, response.headers, body, content)
|
||||
|
||||
def uri(self, path, params=None):
|
||||
return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '')
|
||||
@@ -89,7 +94,7 @@ class HTTPClient(object):
|
||||
if method not in ('get', 'post', 'put', 'delete'):
|
||||
raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method))
|
||||
|
||||
def wrapper(callback, path, params=None, data=''):
|
||||
def wrapper(callback, path, params=None, data='', headers=None):
|
||||
# python-consul doesn't allow to specify ttl smaller then 10 seconds
|
||||
# because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack...
|
||||
if method == 'put' and path == '/v1/session/create':
|
||||
@@ -106,12 +111,13 @@ class HTTPClient(object):
|
||||
# According to the documentation a small random amount of additional wait time is added to the
|
||||
# supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds
|
||||
# up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a
|
||||
# response rather read timeout we will add to the timeout a sligtly bigger value.
|
||||
# response rather read timeout we will add to the timeout a slightly bigger value.
|
||||
kwargs['timeout'] = timeout + max(timeout/15.0, 1)
|
||||
else:
|
||||
kwargs['timeout'] = self._read_timeout
|
||||
kwargs['headers'] = (headers or {}).copy()
|
||||
kwargs['headers'].update(urllib3.make_headers(user_agent=USER_AGENT))
|
||||
token = params.pop('token', self.token) if isinstance(params, dict) else self.token
|
||||
kwargs['headers'] = urllib3.make_headers(user_agent=USER_AGENT)
|
||||
if token:
|
||||
kwargs['headers']['X-Consul-Token'] = token
|
||||
return callback(self.response(self.http.request(method.upper(), self.uri(path, params), **kwargs)))
|
||||
@@ -126,7 +132,7 @@ class ConsulClient(base.Consul):
|
||||
self.token = kwargs.get('token')
|
||||
super(ConsulClient, self).__init__(*args, **kwargs)
|
||||
|
||||
def connect(self, *args, **kwargs):
|
||||
def http_connect(self, *args, **kwargs):
|
||||
kwargs.update(dict(zip(['host', 'port', 'scheme', 'verify'], args)))
|
||||
if self._cert:
|
||||
kwargs['cert'] = self._cert
|
||||
@@ -136,6 +142,9 @@ class ConsulClient(base.Consul):
|
||||
kwargs['token'] = self.token
|
||||
return HTTPClient(**kwargs)
|
||||
|
||||
def connect(self, *args, **kwargs):
|
||||
return self.http_connect(*args, **kwargs)
|
||||
|
||||
def reload_config(self, config):
|
||||
self.http.token = self.token = config.get('token')
|
||||
self.consistency = config.get('consistency', 'default')
|
||||
@@ -218,12 +227,11 @@ class Consul(AbstractDCS):
|
||||
self._last_session_refresh = 0
|
||||
self.__session_checks = config.get('checks', [])
|
||||
self._register_service = config.get('register_service', False)
|
||||
self._previous_loop_register_service = self._register_service
|
||||
self._service_tags = sorted(config.get('service_tags', []))
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
if self._register_service:
|
||||
self._service_tags = config.get('service_tags', [])
|
||||
self._service_name = service_name_from_scope_name(self._scope)
|
||||
if self._scope != self._service_name:
|
||||
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name,
|
||||
self._scope)
|
||||
self._set_service_name()
|
||||
self._service_check_interval = config.get('service_check_interval', '5s')
|
||||
if not self._ctl:
|
||||
self.create_session()
|
||||
@@ -241,7 +249,18 @@ class Consul(AbstractDCS):
|
||||
|
||||
def reload_config(self, config):
|
||||
super(Consul, self).reload_config(config)
|
||||
self._client.reload_config(config.get('consul', {}))
|
||||
|
||||
consul_config = config.get('consul', {})
|
||||
self._client.reload_config(consul_config)
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
self._service_tags = sorted(consul_config.get('service_tags', []))
|
||||
|
||||
should_register_service = consul_config.get('register_service', False)
|
||||
if should_register_service and not self._register_service:
|
||||
self._set_service_name()
|
||||
|
||||
self._previous_loop_register_service = self._register_service
|
||||
self._register_service = should_register_service
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x
|
||||
@@ -328,9 +347,24 @@ class Consul(AbstractDCS):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value'])
|
||||
# get last known leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['Value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['Value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -357,9 +391,9 @@ class Consul(AbstractDCS):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['ModifyIndex'], sync and sync['Value'])
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
|
||||
except NotFound:
|
||||
return Cluster(None, None, None, None, [], None, None, None)
|
||||
return Cluster(None, None, None, None, [], None, None, None, None)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise ConsulError('Consul is not responding properly')
|
||||
@@ -378,14 +412,18 @@ class Consul(AbstractDCS):
|
||||
self._client.kv.delete(self.member_path)
|
||||
create_member = True
|
||||
|
||||
if self._register_service or self._previous_loop_register_service:
|
||||
try:
|
||||
self.update_service(not create_member and member and member.data or {}, data)
|
||||
except Exception:
|
||||
logger.exception('update_service')
|
||||
|
||||
if not create_member and member and deep_compare(data, member.data):
|
||||
return True
|
||||
|
||||
try:
|
||||
args = {} if permanent else {'acquire': self._session}
|
||||
self._client.kv.put(self.member_path, json.dumps(data, separators=(',', ':')), **args)
|
||||
if self._register_service:
|
||||
self.update_service(not create_member and member and member.data or {}, data)
|
||||
return True
|
||||
except InvalidSession:
|
||||
self._session = None
|
||||
@@ -394,6 +432,11 @@ class Consul(AbstractDCS):
|
||||
logger.exception('touch_member')
|
||||
return False
|
||||
|
||||
def _set_service_name(self):
|
||||
self._service_name = service_name_from_scope_name(self._scope)
|
||||
if self._scope != self._service_name:
|
||||
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name, self._scope)
|
||||
|
||||
@catch_consul_errors
|
||||
def register_service(self, service_name, **kwargs):
|
||||
logger.info('Register service %s, params %s', service_name, kwargs)
|
||||
@@ -417,17 +460,22 @@ class Consul(AbstractDCS):
|
||||
deregister='{0}s'.format(self._client.http.ttl * 10))
|
||||
tags = self._service_tags[:]
|
||||
tags.append(role)
|
||||
self._previous_loop_service_tags = self._service_tags
|
||||
|
||||
params = {
|
||||
'service_id': '{0}/{1}'.format(self._scope, self._name),
|
||||
'address': conn_parts.hostname,
|
||||
'port': conn_parts.port,
|
||||
'check': check,
|
||||
'tags': tags
|
||||
'tags': tags,
|
||||
'enable_tag_override': True,
|
||||
}
|
||||
|
||||
if state == 'stopped':
|
||||
if state == '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', 'replica', 'standby-leader']:
|
||||
if state != 'running':
|
||||
return
|
||||
@@ -446,7 +494,10 @@ class Consul(AbstractDCS):
|
||||
if old_data.get(key) != new_data[key]:
|
||||
update = True
|
||||
|
||||
if force or update:
|
||||
if (
|
||||
force or update or self._register_service != self._previous_loop_register_service
|
||||
or self._service_tags != self._previous_loop_service_tags
|
||||
):
|
||||
return self._update_service(new_data)
|
||||
|
||||
@catch_consul_errors
|
||||
@@ -482,8 +533,12 @@ class Consul(AbstractDCS):
|
||||
return self._client.kv.put(self.config_path, value, cas=index)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.kv.put(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.kv.put(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.kv.put(self.status_path, value)
|
||||
|
||||
@catch_consul_errors
|
||||
def _update_leader(self):
|
||||
|
||||
+32
-11
@@ -188,7 +188,8 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
logger.debug("Retrieved list of machines: %s", machines)
|
||||
if machines:
|
||||
random.shuffle(machines)
|
||||
self._update_dns_cache(self._dns_resolver.resolve_async, machines)
|
||||
if not self._use_proxies:
|
||||
self._update_dns_cache(self._dns_resolver.resolve_async, machines)
|
||||
return machines
|
||||
except Exception as e:
|
||||
self.http.clear()
|
||||
@@ -282,13 +283,14 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
except DNSException:
|
||||
return []
|
||||
|
||||
def _get_machines_cache_from_srv(self, srv):
|
||||
def _get_machines_cache_from_srv(self, srv, srv_suffix=None):
|
||||
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
|
||||
This record should contain list of host and peer ports which could be used to run
|
||||
'GET http://{host}:{port}/members' request (peer protocol)"""
|
||||
|
||||
ret = []
|
||||
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
|
||||
r = '{0}-{1}'.format(r, srv_suffix) if srv_suffix else r
|
||||
protocol = 'https' if '-ssl' in r else 'http'
|
||||
endpoint = '/members' if '-server' in r else ''
|
||||
for host, port in self.get_srv_record('_etcd{0}._tcp.{1}'.format(r, srv)):
|
||||
@@ -325,7 +327,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
|
||||
|
||||
machines_cache = []
|
||||
if 'srv' in self._config:
|
||||
machines_cache = self._get_machines_cache_from_srv(self._config['srv'])
|
||||
machines_cache = self._get_machines_cache_from_srv(self._config['srv'], self._config.get('srv_suffix'))
|
||||
|
||||
if not machines_cache and 'hosts' in self._config:
|
||||
machines_cache = list(self._config['hosts'])
|
||||
@@ -404,7 +406,7 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
if self.http is not None:
|
||||
try:
|
||||
self.http.clear()
|
||||
except (ReferenceError, TypeError):
|
||||
except (ReferenceError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
def _prepare_get_members(self, etcd_nodes):
|
||||
@@ -602,9 +604,24 @@ class Etcd(AbstractEtcd):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status.value)
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn.value
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -626,9 +643,9 @@ class Etcd(AbstractEtcd):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync.modifiedIndex, sync and sync.value)
|
||||
|
||||
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
cluster = Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
cluster = Cluster(None, None, None, None, [], None, None, None)
|
||||
cluster = Cluster(None, None, None, None, [], None, None, None, None)
|
||||
except Exception as e:
|
||||
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
|
||||
self._has_failed = False
|
||||
@@ -665,8 +682,12 @@ class Etcd(AbstractEtcd):
|
||||
return self._client.write(self.config_path, value, prevIndex=index or 0)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.set(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.set(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.set(self.status_path, value)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _update_leader(self):
|
||||
|
||||
+39
-11
@@ -98,6 +98,10 @@ class UserEmpty(InvalidArgument):
|
||||
error = "etcdserver: user name is empty"
|
||||
|
||||
|
||||
class AuthFailed(InvalidArgument):
|
||||
error = "etcdserver: authentication failed, invalid user ID or password"
|
||||
|
||||
|
||||
class PermissionDenied(Etcd3ClientError):
|
||||
code = GRPCCode.PermissionDenied
|
||||
error = "etcdserver: permission denied"
|
||||
@@ -186,7 +190,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
|
||||
try:
|
||||
self.authenticate()
|
||||
except Exception as e:
|
||||
except AuthFailed as e:
|
||||
logger.fatal('Etcd3 authentication failed: %r', e)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -258,6 +262,9 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
|
||||
|
||||
def authenticate(self):
|
||||
if self._use_proxies and self._cluster_version is None:
|
||||
kwargs = self._prepare_common_parameters(1)
|
||||
self._ensure_version_prefix(self._base_uri, **kwargs)
|
||||
if self._cluster_version >= (3, 3) and self.username and self.password:
|
||||
logger.info('Trying to authenticate on Etcd...')
|
||||
old_token, self._token = self._token, None
|
||||
@@ -368,6 +375,7 @@ class KVCache(Thread):
|
||||
self._config_key = base64_encode(dcs.config_path)
|
||||
self._leader_key = base64_encode(dcs.leader_path)
|
||||
self._optime_key = base64_encode(dcs.leader_optime_path)
|
||||
self._status_key = base64_encode(dcs.status_path)
|
||||
self._name = base64_encode(dcs._name)
|
||||
self._is_ready = False
|
||||
self._response = None
|
||||
@@ -414,14 +422,15 @@ class KVCache(Thread):
|
||||
new_value = kv.get('value')
|
||||
|
||||
value_changed = old_value != new_value and \
|
||||
(key == self._leader_key or key == self._optime_key and new_value is not None or
|
||||
(key == self._leader_key or key in (self._optime_key, self._status_key) and new_value is not None or
|
||||
key == self._config_key and old_value is not None and new_value is not None)
|
||||
|
||||
if value_changed:
|
||||
logger.debug('%s changed from %s to %s', key, old_value, new_value)
|
||||
|
||||
# We also want to wake up HA loop on replicas if leader optime was updated
|
||||
if value_changed and (key != self._optime_key or self.get(self._leader_key) != self._name):
|
||||
# We also want to wake up HA loop on replicas if leader optime (or status key) was updated
|
||||
if value_changed and (key not in (self._optime_key, self._status_key) or
|
||||
(self.get(self._leader_key) or {}).get('value') != self._name):
|
||||
self._dcs.event.set()
|
||||
|
||||
def _process_message(self, message):
|
||||
@@ -608,7 +617,7 @@ class Etcd3(AbstractEtcd):
|
||||
return self.retry(self._do_refresh_lease)
|
||||
except (Etcd3ClientError, RetryFailedError):
|
||||
logger.exception('refresh_lease')
|
||||
raise Etcd3Error('Failed ro keepalive/grant lease')
|
||||
raise Etcd3Error('Failed to keepalive/grant lease')
|
||||
|
||||
def create_lease(self):
|
||||
while not self._lease:
|
||||
@@ -650,9 +659,24 @@ class Etcd3(AbstractEtcd):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value'])
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -676,7 +700,7 @@ class Etcd3(AbstractEtcd):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['mod_revision'], sync and sync['value'])
|
||||
|
||||
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
cluster = Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
|
||||
except UnsupportedEtcdVersion:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -737,8 +761,12 @@ class Etcd3(AbstractEtcd):
|
||||
return self._client.put(self.config_path, value, mod_revision=index)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.put(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._client.put(self.leader_optime_path, last_lsn)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_status(self, value):
|
||||
return self._client.put(self.status_path, value)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _update_leader(self):
|
||||
|
||||
+51
-25
@@ -55,16 +55,19 @@ class K8sConfig(object):
|
||||
if token:
|
||||
self._headers['authorization'] = 'Bearer ' + token
|
||||
|
||||
def load_incluster_config(self):
|
||||
def load_incluster_config(self, ca_certs=SERVICE_CERT_FILENAME):
|
||||
if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
|
||||
raise self.ConfigException('Service host/port is not set.')
|
||||
if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
|
||||
raise self.ConfigException('Service host/port is set but empty.')
|
||||
if not os.path.isfile(SERVICE_CERT_FILENAME):
|
||||
|
||||
if not os.path.isfile(ca_certs):
|
||||
raise self.ConfigException('Service certificate file does not exists.')
|
||||
with open(SERVICE_CERT_FILENAME) as f:
|
||||
with open(ca_certs) as f:
|
||||
if not f.read():
|
||||
raise self.ConfigException('Cert file exists but empty.')
|
||||
self.pool_config['ca_certs'] = ca_certs
|
||||
|
||||
if not os.path.isfile(SERVICE_TOKEN_FILENAME):
|
||||
raise self.ConfigException('Service token file does not exists.')
|
||||
with open(SERVICE_TOKEN_FILENAME) as f:
|
||||
@@ -72,7 +75,6 @@ class K8sConfig(object):
|
||||
if not token:
|
||||
raise self.ConfigException('Token file exists but empty.')
|
||||
self._make_headers(token=token)
|
||||
self.pool_config['ca_certs'] = SERVICE_CERT_FILENAME
|
||||
self._server = uri('https', (os.environ[SERVICE_HOST_ENV_NAME], os.environ[SERVICE_PORT_ENV_NAME]))
|
||||
|
||||
@staticmethod
|
||||
@@ -613,13 +615,14 @@ class Kubernetes(AbstractDCS):
|
||||
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
|
||||
self._namespace = config.get('namespace') or 'default'
|
||||
self._role_label = config.get('role_label', 'role')
|
||||
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||
config['namespace'] = ''
|
||||
super(Kubernetes, self).__init__(config)
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=KubernetesRetriableException)
|
||||
self._ttl = None
|
||||
try:
|
||||
k8s_config.load_incluster_config()
|
||||
k8s_config.load_incluster_config(ca_certs=self._ca_certs)
|
||||
except k8s_config.ConfigException:
|
||||
k8s_config.load_kube_config(context=config.get('context', 'local'))
|
||||
|
||||
@@ -631,7 +634,8 @@ class Kubernetes(AbstractDCS):
|
||||
port.update({n: p[n] for n in ('name', 'protocol') if p.get(n)})
|
||||
self.__ports.append(k8s_client.V1EndpointPort(**port))
|
||||
|
||||
self._api = CoreV1ApiProxy(config.get('use_endpoints'), config.get('bypass_api_service'))
|
||||
bypass_api_service = not config.get('patronictl') and config.get('bypass_api_service')
|
||||
self._api = CoreV1ApiProxy(config.get('use_endpoints'), bypass_api_service)
|
||||
self._should_create_config_service = self._api.use_endpoints
|
||||
self.reload_config(config)
|
||||
# leader_observed_record, leader_resource_version, and leader_observed_time are used only for leader race!
|
||||
@@ -723,9 +727,19 @@ class Kubernetes(AbstractDCS):
|
||||
self._leader_resource_version = metadata.resource_version if metadata else None
|
||||
annotations = metadata and metadata.annotations or {}
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = annotations.get(self._OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation)
|
||||
# get last known leader lsn
|
||||
last_lsn = annotations.get(self._OPTIME)
|
||||
try:
|
||||
last_lsn = 0 if last_lsn is None else int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get permanent slots state (confirmed_flush_lsn)
|
||||
slots = annotations.get('slots')
|
||||
try:
|
||||
slots = slots and json.loads(slots)
|
||||
except Exception:
|
||||
slots = None
|
||||
|
||||
# get leader
|
||||
leader_record = {n: annotations.get(n) for n in (self._LEADER, 'acquireTime',
|
||||
@@ -759,7 +773,7 @@ class Kubernetes(AbstractDCS):
|
||||
metadata = sync and sync.metadata
|
||||
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise KubernetesError('Kubernetes API is not responding properly')
|
||||
@@ -880,7 +894,10 @@ class Kubernetes(AbstractDCS):
|
||||
return logger.exception('create_config_service failed')
|
||||
self._should_create_config_service = False
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
"""Unused"""
|
||||
|
||||
def _write_status(self, value):
|
||||
"""Unused"""
|
||||
|
||||
def _update_leader(self):
|
||||
@@ -901,16 +918,26 @@ class Kubernetes(AbstractDCS):
|
||||
else:
|
||||
logger.exception('Permission denied' if e.status == 403 else 'Unexpected error from Kubernetes API')
|
||||
return False
|
||||
except RetryFailedError:
|
||||
except (RetryFailedError, K8sException):
|
||||
return False
|
||||
|
||||
deadline = retry.stoptime - time.time()
|
||||
if deadline < 2:
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 1:
|
||||
return False
|
||||
|
||||
retry.sleep_func(1) # Give a chance for ObjectCache to receive the latest version
|
||||
# Try to get the latest version directly from K8s API instead of relying on async cache
|
||||
try:
|
||||
kind = retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
|
||||
except Exception as e:
|
||||
logger.error('Failed to get the leader object "%s": %r', self.leader_path, e)
|
||||
return False
|
||||
|
||||
self._kinds.set(self.leader_path, kind)
|
||||
|
||||
retry.deadline = retry.stoptime - time.time()
|
||||
if retry.deadline < 0.5:
|
||||
return False
|
||||
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
kind_annotations = kind and kind.metadata.annotations or {}
|
||||
kind_resource_version = kind and kind.metadata.resource_version
|
||||
|
||||
@@ -918,10 +945,9 @@ class Kubernetes(AbstractDCS):
|
||||
if kind and (kind_annotations.get(self._LEADER) != self._name or kind_resource_version == resource_version):
|
||||
return False
|
||||
|
||||
retry.deadline = deadline - 1 # Update deadline and retry
|
||||
return self.patch_or_create(self.leader_path, annotations, kind_resource_version, ips=ips, retry=_retry)
|
||||
|
||||
def update_leader(self, last_operation, access_is_restricted=False):
|
||||
def update_leader(self, last_lsn, slots=None):
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
kind_annotations = kind and kind.metadata.annotations or {}
|
||||
|
||||
@@ -933,12 +959,12 @@ class Kubernetes(AbstractDCS):
|
||||
annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
|
||||
'acquireTime': leader_observed_record.get('acquireTime') or now,
|
||||
'transitions': leader_observed_record.get('transitions') or '0'}
|
||||
if last_operation:
|
||||
annotations[self._OPTIME] = last_operation
|
||||
if last_lsn:
|
||||
annotations[self._OPTIME] = str(last_lsn)
|
||||
annotations['slots'] = json.dumps(slots) if slots else None
|
||||
|
||||
resource_version = kind and kind.metadata.resource_version
|
||||
ips = [] if access_is_restricted else self.__ips
|
||||
return self._update_leader_with_retry(annotations, resource_version, ips)
|
||||
return self._update_leader_with_retry(annotations, resource_version, self.__ips)
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
now = datetime.datetime.now(tzutc).isoformat()
|
||||
@@ -1014,12 +1040,12 @@ class Kubernetes(AbstractDCS):
|
||||
def _delete_leader(self):
|
||||
"""Unused"""
|
||||
|
||||
def delete_leader(self, last_operation=None):
|
||||
def delete_leader(self, last_lsn=None):
|
||||
kind = self._kinds.get(self.leader_path)
|
||||
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
|
||||
annotations = {self._LEADER: None}
|
||||
if last_operation:
|
||||
annotations[self._OPTIME] = last_operation
|
||||
if last_lsn:
|
||||
annotations[self._OPTIME] = last_lsn
|
||||
self.patch_or_create(self.leader_path, annotations, kind.metadata.resource_version, True, False, [])
|
||||
self.reset_cluster()
|
||||
|
||||
|
||||
+137
-123
@@ -4,130 +4,124 @@ import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from pysyncobj import SyncObj, SyncObjConf, replicated, FAIL_REASON
|
||||
from pysyncobj.transport import Node, TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.dns_resolver import globalDnsResolver
|
||||
from pysyncobj.node import TCPNode
|
||||
from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.utility import TcpUtility
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from ..utils import validate_directory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageNode(Node):
|
||||
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
|
||||
|
||||
class UtilityTransport(TCPTransport):
|
||||
class _TCPTransport(TCPTransport):
|
||||
|
||||
def __init__(self, syncObj, selfNode, otherNodes):
|
||||
super(UtilityTransport, self).__init__(syncObj, selfNode, otherNodes)
|
||||
self._selfIsReadonlyNode = False
|
||||
super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes)
|
||||
self.setOnUtilityMessageCallback('members', syncObj.getMembers)
|
||||
|
||||
def _connectIfNecessarySingle(self, node):
|
||||
pass
|
||||
|
||||
def connectionState(self, node):
|
||||
return self._connections[node].state
|
||||
|
||||
def isDisconnected(self, node):
|
||||
return self.connectionState(node) == CONNECTION_STATE.DISCONNECTED
|
||||
|
||||
def connectIfRequiredSingle(self, node):
|
||||
if self.isDisconnected(node):
|
||||
return self._connections[node].connect(node.ip, node.port)
|
||||
|
||||
def disconnectSingle(self, node):
|
||||
self._connections[node].disconnect()
|
||||
try:
|
||||
return super(_TCPTransport, self)._connectIfNecessarySingle(node)
|
||||
except Exception as e:
|
||||
logger.debug('Connection to %s failed: %r', node, e)
|
||||
return False
|
||||
|
||||
|
||||
class SyncObjUtility(SyncObj):
|
||||
def resolve_host(self):
|
||||
return globalDnsResolver().resolve(self.host)
|
||||
|
||||
|
||||
setattr(TCPNode, 'ip', property(resolve_host))
|
||||
|
||||
|
||||
class SyncObjUtility(object):
|
||||
|
||||
def __init__(self, otherNodes, conf):
|
||||
autoTick = conf.autoTick
|
||||
conf.autoTick = False
|
||||
super(SyncObjUtility, self).__init__(None, otherNodes, conf, transportClass=UtilityTransport)
|
||||
conf.autoTick = autoTick
|
||||
self._SyncObj__transport.setOnMessageReceivedCallback(self._onMessageReceived)
|
||||
self.__result = None
|
||||
self._nodes = otherNodes
|
||||
self._utility = TcpUtility(conf.password)
|
||||
|
||||
def setPartnerNode(self, partner):
|
||||
self.__node = partner
|
||||
def executeCommand(self, command):
|
||||
try:
|
||||
return self._utility.executeCommand(self.__node, command)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def sendMessage(self, message):
|
||||
# Abuse the fact that node address is send as a first message
|
||||
self._SyncObj__transport._selfNode = MessageNode(message)
|
||||
self._SyncObj__transport.connectIfRequiredSingle(self.__node)
|
||||
while not self._SyncObj__transport.isDisconnected(self.__node):
|
||||
self._poller.poll(0.5)
|
||||
return self.__result
|
||||
|
||||
def _onMessageReceived(self, _, message):
|
||||
self.__result = message
|
||||
self._SyncObj__transport.disconnectSingle(self.__node)
|
||||
|
||||
|
||||
class MyTCPTransport(TCPTransport):
|
||||
|
||||
def _onIncomingMessageReceived(self, conn, message):
|
||||
if self._syncObj.encryptor and not conn.sendRandKey:
|
||||
conn.sendRandKey = message
|
||||
conn.recvRandKey = os.urandom(32)
|
||||
conn.send(conn.recvRandKey)
|
||||
return
|
||||
|
||||
# Utility messages
|
||||
if isinstance(message, list) and message[0] == 'members':
|
||||
conn.send(self._syncObj._get_members())
|
||||
return True
|
||||
|
||||
return super(MyTCPTransport, self)._onIncomingMessageReceived(conn, message)
|
||||
def getMembers(self):
|
||||
for self.__node in self._nodes:
|
||||
response = self.executeCommand(['members'])
|
||||
if response:
|
||||
return [member['addr'] for member in response]
|
||||
|
||||
|
||||
class DynMemberSyncObj(SyncObj):
|
||||
|
||||
def __init__(self, selfAddress, partnerAddrs, conf):
|
||||
add_self = False
|
||||
if selfAddress:
|
||||
utility = SyncObjUtility(partnerAddrs, conf)
|
||||
for node in utility._SyncObj__otherNodes:
|
||||
utility.setPartnerNode(node)
|
||||
response = utility.sendMessage(['members'])
|
||||
if response:
|
||||
partnerAddrs = [member['addr'] for member in response if member['addr'] != selfAddress]
|
||||
add_self = len(partnerAddrs) == len(response)
|
||||
break
|
||||
self.__early_apply_local_log = selfAddress is not None
|
||||
self.applied_local_log = False
|
||||
|
||||
utility = SyncObjUtility(partnerAddrs, conf)
|
||||
members = utility.getMembers()
|
||||
add_self = members and selfAddress not in members
|
||||
|
||||
partnerAddrs = [member for member in (members or partnerAddrs) if member != selfAddress]
|
||||
|
||||
super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=_TCPTransport)
|
||||
|
||||
super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=MyTCPTransport)
|
||||
if add_self:
|
||||
threading.Thread(target=utility.sendMessage, args=(['add', selfAddress],)).start()
|
||||
thread = threading.Thread(target=utility.executeCommand, args=(['add', selfAddress],))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def _get_members(self):
|
||||
ret = [{'addr': node.id, 'leader': node == self._getLeader(),
|
||||
'status': CONNECTION_STATE.CONNECTED if node in self._SyncObj__connectedNodes
|
||||
else CONNECTION_STATE.DISCONNECTED} for node in self._SyncObj__otherNodes]
|
||||
ret.append({'addr': self._SyncObj__selfNode.id, 'leader': self._isLeader(),
|
||||
'status': CONNECTION_STATE.CONNECTED})
|
||||
return ret
|
||||
def getMembers(self, args, callback):
|
||||
callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED
|
||||
if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes] +
|
||||
[{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
|
||||
|
||||
def _SyncObj__doChangeCluster(self, request, reverse=False):
|
||||
ret = False
|
||||
if not self._SyncObj__selfNode or request[0] != 'add' or reverse or request[1] != self._SyncObj__selfNode.id:
|
||||
ret = super(DynMemberSyncObj, self)._SyncObj__doChangeCluster(request, reverse)
|
||||
if ret:
|
||||
self.forceLogCompaction()
|
||||
return ret
|
||||
def _onTick(self, timeToWait=0.0):
|
||||
super(DynMemberSyncObj, self)._onTick(timeToWait)
|
||||
|
||||
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
|
||||
# In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
|
||||
# We are using the `applied_local_log` property for that, but not calling the callback function.
|
||||
if self.__early_apply_local_log and not self.applied_local_log and self.raftLastApplied == self.raftCommitIndex:
|
||||
self.applied_local_log = True
|
||||
|
||||
|
||||
class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
def __init__(self, selfAddress, partnerAddrs, conf, on_set=None, on_delete=None):
|
||||
def __init__(self, on_ready, on_set, on_delete, **config):
|
||||
self.__thread = None
|
||||
self.__on_set = on_set
|
||||
self.__on_delete = on_delete
|
||||
self.__limb = {}
|
||||
self.__retry_timeout = None
|
||||
self.__early_apply_local_log = selfAddress is not None
|
||||
self.applied_local_log = False
|
||||
super(KVStoreTTL, self).__init__(selfAddress, partnerAddrs, conf)
|
||||
|
||||
self_addr = config.get('self_addr')
|
||||
partner_addrs = set(config.get('partner_addrs', []))
|
||||
if config.get('patronictl'):
|
||||
if self_addr:
|
||||
partner_addrs.add(self_addr)
|
||||
self_addr = None
|
||||
|
||||
# Create raft data_dir if necessary
|
||||
raft_data_dir = config.get('data_dir', '')
|
||||
if raft_data_dir != '':
|
||||
validate_directory(raft_data_dir)
|
||||
|
||||
file_template = (self_addr or '')
|
||||
file_template = file_template.replace(':', '_') if os.name == 'nt' else file_template
|
||||
file_template = os.path.join(raft_data_dir, file_template)
|
||||
conf = SyncObjConf(password=config.get('password'), autoTick=False, appendEntriesUseBatch=False,
|
||||
bindAddress=config.get('bind_addr'), dnsFailCacheTime=(config.get('loop_wait') or 10),
|
||||
dnsCacheTime=(config.get('ttl') or 30), commandsWaitLeader=config.get('commandsWaitLeader'),
|
||||
fullDumpFile=(file_template + '.dump' if self_addr else None),
|
||||
journalFile=(file_template + '.journal' if self_addr else None),
|
||||
onReady=on_ready, dynamicMembershipChange=True)
|
||||
|
||||
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf)
|
||||
self.__data = {}
|
||||
|
||||
@staticmethod
|
||||
@@ -174,7 +168,7 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
if old_value and old_value['created'] != value['created']:
|
||||
value['created'] = value['updated']
|
||||
value['index'] = self._SyncObj__raftLastApplied + 1
|
||||
value['index'] = self.raftLastApplied + 1
|
||||
|
||||
self.__data[key] = value
|
||||
if self.__on_set:
|
||||
@@ -241,27 +235,29 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
return {k: v for k, v in self.__data.items() if k.startswith(key)}
|
||||
|
||||
def _onTick(self, timeToWait=0.0):
|
||||
# The SyncObj starts applying the local log only when there is at least one node connected.
|
||||
# We want to change this behavior and apply the local log even when there is nobody except us.
|
||||
# It gives us at least some picture about the last known cluster state.
|
||||
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__needLoadDumpFile:
|
||||
self._SyncObj__raftCommitIndex = self._SyncObj__getCurrentLogIndex()
|
||||
self._SyncObj__raftCurrentTerm = self._SyncObj__getCurrentLogTerm()
|
||||
|
||||
super(KVStoreTTL, self)._onTick(timeToWait)
|
||||
|
||||
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
|
||||
# In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
|
||||
# We are using the `applied_local_log` property for that, but not calling the callback function.
|
||||
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__raftCommitIndex != 1 and \
|
||||
self._SyncObj__raftLastApplied == self._SyncObj__raftCommitIndex:
|
||||
self.applied_local_log = True
|
||||
|
||||
if self._isLeader():
|
||||
self.__expire_keys()
|
||||
else:
|
||||
self.__limb.clear()
|
||||
|
||||
def _autoTickThread(self):
|
||||
self.__destroying = False
|
||||
while not self.__destroying:
|
||||
self.doTick(self.conf.autoTickPeriod)
|
||||
|
||||
def startAutoTick(self):
|
||||
self.__thread = threading.Thread(target=self._autoTickThread)
|
||||
self.__thread.daemon = True
|
||||
self.__thread.start()
|
||||
|
||||
def destroy(self):
|
||||
if self.__thread:
|
||||
self.__destroying = True
|
||||
self.__thread.join()
|
||||
super(KVStoreTTL, self).destroy()
|
||||
|
||||
|
||||
class Raft(AbstractDCS):
|
||||
|
||||
@@ -269,28 +265,24 @@ class Raft(AbstractDCS):
|
||||
super(Raft, self).__init__(config)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
|
||||
self_addr = None if self._ctl else config.get('self_addr')
|
||||
template = os.path.join(config.get('data_dir', ''), self_addr or '')
|
||||
files = {'journalFile': template + '.journal', 'fullDumpFile': template + '.dump'} if self_addr else {}
|
||||
|
||||
ready_event = threading.Event()
|
||||
conf = SyncObjConf(commandsWaitLeader=False, appendEntriesUseBatch=False, onReady=ready_event.set,
|
||||
dynamicMembershipChange=True, **files)
|
||||
self._sync_obj = KVStoreTTL(self_addr, config.get('partner_addrs', []), conf, self._on_set, self._on_delete)
|
||||
self._sync_obj = KVStoreTTL(ready_event.set, self._on_set, self._on_delete, commandsWaitLeader=False, **config)
|
||||
self._sync_obj.startAutoTick()
|
||||
|
||||
while True:
|
||||
ready_event.wait(5)
|
||||
if ready_event.isSet() or self._sync_obj.applied_local_log:
|
||||
break
|
||||
else:
|
||||
logger.info('waiting on raft')
|
||||
self._sync_obj.forceLogCompaction()
|
||||
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
|
||||
|
||||
def _on_set(self, key, value):
|
||||
leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
|
||||
if key == value['created'] == value['updated'] and \
|
||||
(key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \
|
||||
key == self.leader_optime_path and leader != self._name or key in (self.config_path, self.sync_path):
|
||||
key in (self.leader_optime_path, self.status_path) and leader != self._name or \
|
||||
key in (self.config_path, self.sync_path):
|
||||
self.event.set()
|
||||
|
||||
def _on_delete(self, key):
|
||||
@@ -307,6 +299,10 @@ class Raft(AbstractDCS):
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
self._sync_obj.set_retry_timeout(retry_timeout)
|
||||
|
||||
def reload_config(self, config):
|
||||
super(Raft, self).reload_config(config)
|
||||
globalDnsResolver().setTimeouts(self.ttl, self.loop_wait)
|
||||
|
||||
@staticmethod
|
||||
def member(key, value):
|
||||
return Member.from_node(value['index'], os.path.basename(key), None, value['value'])
|
||||
@@ -315,7 +311,7 @@ class Raft(AbstractDCS):
|
||||
prefix = self.client_path('')
|
||||
response = self._sync_obj.get(prefix, recursive=True)
|
||||
if not response:
|
||||
return Cluster(None, None, None, None, [], None, None, None)
|
||||
return Cluster(None, None, None, None, [], None, None, None, None)
|
||||
nodes = {os.path.relpath(key, prefix).replace('\\', '/'): value for key, value in response.items()}
|
||||
|
||||
# get initialize flag
|
||||
@@ -330,9 +326,24 @@ class Raft(AbstractDCS):
|
||||
history = nodes.get(self._HISTORY)
|
||||
history = history and TimelineHistory.from_node(history['index'], history['value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value'])
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get list of members
|
||||
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -353,10 +364,13 @@ class Raft(AbstractDCS):
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['index'], sync and sync['value'])
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._sync_obj.set(self.leader_optime_path, last_operation, timeout=1)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1)
|
||||
|
||||
def _write_status(self, value):
|
||||
return self._sync_obj.set(self.status_path, value, timeout=1)
|
||||
|
||||
def _update_leader(self):
|
||||
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, prevValue=self._name)
|
||||
|
||||
+75
-36
@@ -4,11 +4,13 @@ import select
|
||||
import time
|
||||
|
||||
from kazoo.client import KazooClient, KazooState, KazooRetry
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError, SessionExpiredError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import deep_compare
|
||||
from kazoo.protocol.states import KeeperState
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +56,20 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
||||
raise select.error(9, str(e))
|
||||
|
||||
|
||||
class PatroniKazooClient(KazooClient):
|
||||
|
||||
def _call(self, request, async_object):
|
||||
# Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if
|
||||
# the connection is in the SUSPENDED state and Patroni was strongly relying on it.
|
||||
# The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued.
|
||||
# We override the `_call()` method in order to keep the old behavior.
|
||||
|
||||
if self._state == KeeperState.CONNECTING:
|
||||
async_object.set_exception(SessionExpiredError())
|
||||
return False
|
||||
return super(PatroniKazooClient, self)._call(request, async_object)
|
||||
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
@@ -67,14 +83,14 @@ class ZooKeeper(AbstractDCS):
|
||||
'cert': 'certfile', 'key': 'keyfile', 'key_password': 'keyfile_password'}
|
||||
kwargs = {v: config[k] for k, v in mapping.items() if k in config}
|
||||
|
||||
self._client = KazooClient(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(deadline=config['retry_timeout'],
|
||||
max_delay=1, max_tries=-1, sleep_func=time.sleep), **kwargs)
|
||||
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
|
||||
self._client.add_listener(self.session_listener)
|
||||
|
||||
self._fetch_cluster = True
|
||||
self._fetch_optime = True
|
||||
self._fetch_status = True
|
||||
|
||||
self._orig_kazoo_connect = self._client._connection._connect
|
||||
self._client._connection._connect = self._kazoo_connect
|
||||
@@ -100,13 +116,13 @@ class ZooKeeper(AbstractDCS):
|
||||
if state in [KazooState.SUSPENDED, KazooState.LOST]:
|
||||
self.cluster_watcher(None)
|
||||
|
||||
def optime_watcher(self, event):
|
||||
self._fetch_optime = True
|
||||
def status_watcher(self, event):
|
||||
self._fetch_status = True
|
||||
self.event.set()
|
||||
|
||||
def cluster_watcher(self, event):
|
||||
self._fetch_cluster = True
|
||||
self.optime_watcher(event)
|
||||
self.status_watcher(event)
|
||||
|
||||
def reload_config(self, config):
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
@@ -123,13 +139,14 @@ class ZooKeeper(AbstractDCS):
|
||||
# the same time, set_ttl method will reestablish connection and return
|
||||
# `!True`, otherwise we will close existing connection and let kazoo
|
||||
# open the new one.
|
||||
if not self.set_ttl(int(config['ttl'] * 1000)) and loop_wait_changed:
|
||||
if not self.set_ttl(config['ttl']) and loop_wait_changed:
|
||||
self._client._connection._socket.close()
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
"""It is not possible to change ttl (session_timeout) in zookeeper without
|
||||
destroying old session and creating the new one. This method returns `!True`
|
||||
if session_timeout has been changed (`restart()` has been called)."""
|
||||
ttl = int(ttl * 1000)
|
||||
if self._client._session_timeout != ttl:
|
||||
self._client._session_timeout = ttl
|
||||
self._client.restart()
|
||||
@@ -137,7 +154,7 @@ class ZooKeeper(AbstractDCS):
|
||||
|
||||
@property
|
||||
def ttl(self):
|
||||
return self._client._session_timeout
|
||||
return self._client._session_timeout / 1000.0
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
retry = self._client.retry if isinstance(self._client.retry, KazooRetry) else self._client._retry
|
||||
@@ -150,11 +167,29 @@ class ZooKeeper(AbstractDCS):
|
||||
except NoNodeError:
|
||||
return None
|
||||
|
||||
def get_leader_optime(self, leader):
|
||||
watch = self.optime_watcher if not leader or leader.name != self._name else None
|
||||
optime = self.get_node(self.leader_optime_path, watch)
|
||||
self._fetch_optime = False
|
||||
return optime and int(optime[0]) or 0
|
||||
def get_status(self, leader):
|
||||
watch = self.status_watcher if not leader or leader.name != self._name else None
|
||||
|
||||
status = self.get_node(self.status_path, watch)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status[0])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = self.get_node(self.leader_optime_path, watch)
|
||||
last_lsn = last_lsn and last_lsn[0]
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn)
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
self._fetch_status = False
|
||||
return last_lsn, slots
|
||||
|
||||
@staticmethod
|
||||
def member(name, value, znode):
|
||||
@@ -166,11 +201,10 @@ class ZooKeeper(AbstractDCS):
|
||||
except NoNodeError:
|
||||
return []
|
||||
|
||||
def load_members(self, sync_standby):
|
||||
def load_members(self):
|
||||
members = []
|
||||
for member in self.get_children(self.members_path, self.cluster_watcher):
|
||||
watch = member in sync_standby and self.cluster_watcher or None
|
||||
data = self.get_node(self.members_path + member, watch)
|
||||
data = self.get_node(self.members_path + member)
|
||||
if data is not None:
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
@@ -198,8 +232,7 @@ class ZooKeeper(AbstractDCS):
|
||||
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
|
||||
|
||||
# get list of members
|
||||
sync_standby = sync.leader == self._name and sync.members or []
|
||||
members = self.load_members(sync_standby) if self._MEMBERS[:-1] in nodes else []
|
||||
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
|
||||
|
||||
# get leader
|
||||
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
|
||||
@@ -217,14 +250,14 @@ class ZooKeeper(AbstractDCS):
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self._fetch_cluster = member.index == -1
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = self._OPTIME in nodes and self.get_leader_optime(leader)
|
||||
# get last known leader lsn and slots
|
||||
last_lsn, slots = self.get_status(leader)
|
||||
|
||||
# failover key
|
||||
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||
failover = failover and Failover.from_node(failover[1].version, failover[0])
|
||||
|
||||
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
|
||||
|
||||
def _load_cluster(self):
|
||||
cluster = self.cluster
|
||||
@@ -235,13 +268,15 @@ class ZooKeeper(AbstractDCS):
|
||||
logger.exception('get_cluster')
|
||||
self.cluster_watcher(None)
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
# Optime ZNode was updated or doesn't exist and we are not leader
|
||||
elif (self._fetch_optime and not self._fetch_cluster or not cluster.last_leader_operation) and\
|
||||
# The /status ZNode was updated or doesn't exist and we are not leader
|
||||
elif (self._fetch_status and not self._fetch_cluster or not cluster.last_lsn
|
||||
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots) and\
|
||||
not (cluster.leader and cluster.leader.name == self._name):
|
||||
try:
|
||||
optime = self.get_leader_optime(cluster.leader)
|
||||
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, optime,
|
||||
cluster.members, cluster.failover, cluster.sync, cluster.history)
|
||||
last_lsn, slots = self.get_status(cluster.leader)
|
||||
self.event.clear()
|
||||
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn,
|
||||
cluster.members, cluster.failover, cluster.sync, cluster.history, slots)
|
||||
except Exception:
|
||||
pass
|
||||
return cluster
|
||||
@@ -335,8 +370,11 @@ class ZooKeeper(AbstractDCS):
|
||||
def take_leader(self):
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._set_or_create(self.leader_optime_path, last_operation)
|
||||
def _write_leader_optime(self, last_lsn):
|
||||
return self._set_or_create(self.leader_optime_path, last_lsn)
|
||||
|
||||
def _write_status(self, value):
|
||||
return self._set_or_create(self.status_path, value)
|
||||
|
||||
def _update_leader(self):
|
||||
return True
|
||||
@@ -372,6 +410,7 @@ class ZooKeeper(AbstractDCS):
|
||||
return self.set_sync_state_value("{}", index)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
if super(ZooKeeper, self).watch(leader_index, timeout) and not self._fetch_optime:
|
||||
ret = super(ZooKeeper, self).watch(leader_index, timeout + 0.5)
|
||||
if ret and not self._fetch_status:
|
||||
self._fetch_cluster = True
|
||||
return self._fetch_cluster
|
||||
return ret or self._fetch_cluster
|
||||
|
||||
+105
-61
@@ -66,12 +66,12 @@ class Ha(object):
|
||||
self.old_cluster = None
|
||||
self._is_leader = False
|
||||
self._is_leader_lock = RLock()
|
||||
self._leader_access_is_restricted = False
|
||||
self._was_paused = False
|
||||
self._leader_timeline = None
|
||||
self.recovering = False
|
||||
self._async_response = CriticalTask()
|
||||
self._crash_recovery_executed = False
|
||||
self._crash_recovery_started = None
|
||||
self._start_timeout = None
|
||||
self._async_executor = AsyncExecutor(self.state_handler.cancellable, self.wakeup)
|
||||
self.watchdog = patroni.watchdog
|
||||
@@ -84,7 +84,7 @@ class Ha(object):
|
||||
self._disable_sync = 0
|
||||
|
||||
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
|
||||
# already running as replica was aborted due to cluster not beeing initialized in DCS.
|
||||
# already running as replica was aborted due to cluster not being initialized in DCS.
|
||||
self._join_aborted = False
|
||||
|
||||
# used only in backoff after failing a pre_promote script
|
||||
@@ -120,16 +120,12 @@ class Ha(object):
|
||||
|
||||
def is_leader(self):
|
||||
with self._is_leader_lock:
|
||||
return self._is_leader > time.time() and not self._leader_access_is_restricted
|
||||
return self._is_leader > time.time()
|
||||
|
||||
def set_is_leader(self, value):
|
||||
with self._is_leader_lock:
|
||||
self._is_leader = time.time() + self.dcs.ttl if value else 0
|
||||
|
||||
def set_leader_access_is_restricted(self, value):
|
||||
with self._is_leader_lock:
|
||||
self._leader_access_is_restricted = value
|
||||
|
||||
def load_cluster_from_dcs(self):
|
||||
cluster = self.dcs.get_cluster()
|
||||
|
||||
@@ -144,19 +140,23 @@ class Ha(object):
|
||||
self._leader_timeline = None if cluster.is_unlocked() else cluster.leader.timeline
|
||||
|
||||
def acquire_lock(self):
|
||||
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
|
||||
ret = self.dcs.attempt_to_acquire_leader()
|
||||
self.set_is_leader(ret)
|
||||
return ret
|
||||
|
||||
def update_lock(self, write_leader_optime=False):
|
||||
last_operation = None
|
||||
last_lsn = slots = None
|
||||
if write_leader_optime:
|
||||
try:
|
||||
last_operation = self.state_handler.last_operation()
|
||||
last_lsn = self.state_handler.last_operation()
|
||||
slots = self.state_handler.slots()
|
||||
except Exception:
|
||||
logger.exception('Exception when called state_handler.last_operation()')
|
||||
ret = self.dcs.update_leader(last_operation, self._leader_access_is_restricted)
|
||||
try:
|
||||
ret = self.dcs.update_leader(last_lsn, slots)
|
||||
except Exception:
|
||||
logger.exception('Unexpected exception raised from update_leader, please report it as a BUG')
|
||||
ret = False
|
||||
self.set_is_leader(ret)
|
||||
if ret:
|
||||
self.watchdog.keepalive()
|
||||
@@ -329,6 +329,7 @@ class Ha(object):
|
||||
and not self._crash_recovery_executed and \
|
||||
(self.cluster.is_unlocked() or self._rewind.can_rewind):
|
||||
self._crash_recovery_executed = True
|
||||
self._crash_recovery_started = time.time()
|
||||
msg = 'doing crash recovery in a single user mode'
|
||||
return self._async_executor.try_run_async(msg, self._rewind.ensure_clean_shutdown) or msg
|
||||
|
||||
@@ -403,7 +404,7 @@ class Ha(object):
|
||||
self.state_handler.set_role('replica')
|
||||
|
||||
if not node_to_follow:
|
||||
return 'no action'
|
||||
return 'no action. I am ({0})'.format(self.state_handler.name)
|
||||
elif is_leader:
|
||||
self.demote('immediate-nolock')
|
||||
return demote_reason
|
||||
@@ -416,6 +417,9 @@ class Ha(object):
|
||||
if msg:
|
||||
return msg
|
||||
|
||||
if not self.is_paused():
|
||||
self.state_handler.handle_parameter_change()
|
||||
|
||||
role = 'standby_leader' if isinstance(node_to_follow, RemoteMember) and self.has_lock(False) else 'replica'
|
||||
# It might happen that leader key in the standby cluster references non-exiting member.
|
||||
# In this case it is safe to continue running without changing recovery.conf
|
||||
@@ -454,7 +458,9 @@ class Ha(object):
|
||||
if self.is_synchronous_mode():
|
||||
sync_node_count = self.patroni.config['synchronous_node_count']
|
||||
current = self.cluster.sync.leader and self.cluster.sync.members or []
|
||||
picked, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster, sync_node_count)
|
||||
picked, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster, sync_node_count,
|
||||
self.patroni.config[
|
||||
'maximum_lag_on_syncnode'])
|
||||
if set(picked) != set(current):
|
||||
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
|
||||
sync_common = list(set(current).intersection(set(allow_promote)))
|
||||
@@ -478,7 +484,9 @@ class Ha(object):
|
||||
# Wait for PostgreSQL to enable synchronous mode and see if we can immediately set sync_standby
|
||||
time.sleep(2)
|
||||
_, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster,
|
||||
sync_node_count)
|
||||
sync_node_count,
|
||||
self.patroni.config[
|
||||
'maximum_lag_on_syncnode'])
|
||||
if allow_promote and set(allow_promote) != set(sync_common):
|
||||
try:
|
||||
cluster = self.dcs.get_cluster()
|
||||
@@ -543,7 +551,7 @@ class Ha(object):
|
||||
if master_timeline == 1:
|
||||
if cluster_history:
|
||||
self.dcs.set_history_value('[]')
|
||||
elif not cluster_history or cluster_history[-1][0] != master_timeline - 1 or len(cluster_history[-1]) != 4:
|
||||
elif not cluster_history or cluster_history[-1][0] != master_timeline - 1 or len(cluster_history[-1]) != 5:
|
||||
cluster_history = {line[0]: line for line in cluster_history or []}
|
||||
history = self.state_handler.get_history(master_timeline)
|
||||
if history and self.cluster.config:
|
||||
@@ -551,9 +559,11 @@ class Ha(object):
|
||||
for line in history:
|
||||
# enrich current history with promotion timestamps stored in DCS
|
||||
if len(line) == 3 and line[0] in cluster_history \
|
||||
and len(cluster_history[line[0]]) == 4 \
|
||||
and len(cluster_history[line[0]]) >= 4 \
|
||||
and cluster_history[line[0]][1] == line[1]:
|
||||
line.append(cluster_history[line[0]][3])
|
||||
if len(cluster_history[line[0]]) == 5:
|
||||
line.append(cluster_history[line[0]][4])
|
||||
self.dcs.set_history_value(json.dumps(history, separators=(',', ':')))
|
||||
|
||||
def enforce_follow_remote_master(self, message):
|
||||
@@ -603,8 +613,6 @@ class Ha(object):
|
||||
return 'Postponing promotion because synchronous replication state was updated by somebody else'
|
||||
self.state_handler.config.set_synchronous_standby(['*'] if self.is_synchronous_mode_strict() else [])
|
||||
if self.state_handler.role != 'master':
|
||||
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
|
||||
|
||||
def on_success():
|
||||
self._rewind.reset_state()
|
||||
logger.info("cleared rewind state after becoming the leader")
|
||||
@@ -612,8 +620,7 @@ class Ha(object):
|
||||
with self._async_response:
|
||||
self._async_response.reset()
|
||||
self._async_executor.try_run_async('promote', self.state_handler.promote,
|
||||
args=(self.dcs.loop_wait, self._async_response, on_success,
|
||||
self._leader_access_is_restricted))
|
||||
args=(self.dcs.loop_wait, self._async_response, on_success))
|
||||
return promote_message
|
||||
|
||||
def fetch_node_status(self, member):
|
||||
@@ -643,14 +650,13 @@ class Ha(object):
|
||||
:param wal_position: Current wal position.
|
||||
:returns True when node is lagging
|
||||
"""
|
||||
lag = (self.cluster.last_leader_operation or 0) - wal_position
|
||||
lag = (self.cluster.last_lsn or 0) - wal_position
|
||||
return lag > self.patroni.config.get('maximum_lag_on_failover', 0)
|
||||
|
||||
def _is_healthiest_node(self, members, check_replication_lag=True):
|
||||
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
|
||||
|
||||
# We don't call `last_operation()` here because it returns a string
|
||||
_, my_wal_position, _ = self.state_handler.timeline_wal_position()
|
||||
my_wal_position = self.state_handler.last_operation()
|
||||
if check_replication_lag and self.is_lagging(my_wal_position):
|
||||
logger.info('My wal position exceeds maximum replication lag')
|
||||
return False # Too far behind last reported wal position on master
|
||||
@@ -761,8 +767,10 @@ class Ha(object):
|
||||
if self.state_handler.is_starting(): # postgresql still starting up is unhealthy
|
||||
return False
|
||||
|
||||
if self.state_handler.is_leader(): # leader is always the healthiest
|
||||
return True
|
||||
if self.state_handler.is_leader():
|
||||
# in pause leader is the healthiest only when no initialize or sysid matches with initialize!
|
||||
return not self.is_paused() or not self.cluster.initialize\
|
||||
or self.state_handler.sysid == self.cluster.initialize
|
||||
|
||||
if self.is_paused():
|
||||
return False
|
||||
@@ -774,6 +782,7 @@ class Ha(object):
|
||||
return self.manual_failover_process_no_leader()
|
||||
|
||||
if not self.watchdog.is_healthy:
|
||||
logger.warning('Watchdog device is not usable')
|
||||
return False
|
||||
|
||||
# When in sync mode, only last known master and sync standby are allowed to promote automatically.
|
||||
@@ -789,13 +798,13 @@ class Ha(object):
|
||||
|
||||
return self._is_healthiest_node(members.values())
|
||||
|
||||
def _delete_leader(self, last_operation=None):
|
||||
def _delete_leader(self, last_lsn=None):
|
||||
self.set_is_leader(False)
|
||||
self.dcs.delete_leader(last_operation)
|
||||
self.dcs.delete_leader(last_lsn)
|
||||
self.dcs.reset_cluster()
|
||||
|
||||
def release_leader_key_voluntarily(self, last_operation=None):
|
||||
self._delete_leader(last_operation)
|
||||
def release_leader_key_voluntarily(self, last_lsn=None):
|
||||
self._delete_leader(last_lsn)
|
||||
self.touch_member()
|
||||
logger.info("Leader key released")
|
||||
|
||||
@@ -976,11 +985,6 @@ class Ha(object):
|
||||
self._delete_leader()
|
||||
return 'removed leader lock because postgres is not running as master'
|
||||
|
||||
if self.state_handler.is_leader() and self._leader_access_is_restricted:
|
||||
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
|
||||
self.state_handler.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
self.set_leader_access_is_restricted(False)
|
||||
|
||||
if self.update_lock(True):
|
||||
msg = self.process_manual_failover_from_leader()
|
||||
if msg is not None:
|
||||
@@ -993,14 +997,14 @@ class Ha(object):
|
||||
# in case of standby cluster we don't really need to
|
||||
# enforce anything, since the leader is not a master.
|
||||
# So just remind the role.
|
||||
msg = 'no action. i am the standby leader with the lock' \
|
||||
msg = 'no action. I am ({0}) the standby leader with the lock'.format(self.state_handler.name) \
|
||||
if self.state_handler.role == 'standby_leader' else \
|
||||
'promoted self to a standby leader because i had the session lock'
|
||||
return self.enforce_follow_remote_master(msg)
|
||||
else:
|
||||
return self.enforce_master_role(
|
||||
'no action. i am the leader with the lock',
|
||||
'promoted self to leader because i had the session lock'
|
||||
'no action. I am ({0}) the leader with the lock'.format(self.state_handler.name),
|
||||
'promoted self to leader because I had the session lock'
|
||||
)
|
||||
else:
|
||||
# Either there is no connection to DCS or someone else acquired the lock
|
||||
@@ -1013,12 +1017,15 @@ class Ha(object):
|
||||
else:
|
||||
return 'not promoting because failed to update leader lock in DCS'
|
||||
else:
|
||||
logger.info('does not have lock')
|
||||
logger.debug('does not have lock')
|
||||
lock_owner = self.cluster.leader and self.cluster.leader.name
|
||||
if self.is_standby_cluster():
|
||||
return self.follow('cannot be a real master in standby cluster',
|
||||
'no action. i am a secondary and i am following a standby leader', refresh=False)
|
||||
return self.follow('demoting self because i do not have the lock and i was a leader',
|
||||
'no action. i am a secondary and i am following a leader', refresh=False)
|
||||
return self.follow('cannot be a real primary in a standby cluster',
|
||||
'no action. I am a secondary ({0}) and following a standby leader ({1})'.format(
|
||||
self.state_handler.name, lock_owner), refresh=False)
|
||||
return self.follow('demoting self because I do not have the lock and I was a leader',
|
||||
'no action. I am a secondary ({0}) and following a leader ({1})'.format(
|
||||
self.state_handler.name, lock_owner), refresh=False)
|
||||
|
||||
def evaluate_scheduled_restart(self):
|
||||
if self._async_executor.busy: # Restart already in progress
|
||||
@@ -1165,8 +1172,16 @@ class Ha(object):
|
||||
Figure out what to do with the task AsyncExecutor is performing.
|
||||
"""
|
||||
if self.has_lock() and self.update_lock():
|
||||
if self._async_executor.scheduled_action == 'doing crash recovery in a single user mode':
|
||||
time_left = self.patroni.config['master_start_timeout'] - (time.time() - self._crash_recovery_started)
|
||||
if time_left <= 0 and self.is_failover_possible(self.cluster.members):
|
||||
logger.info("Demoting self because crash recovery is taking too long")
|
||||
self.state_handler.cancellable.cancel(True)
|
||||
self.demote('immediate')
|
||||
return 'terminated crash recovery because of startup timeout'
|
||||
|
||||
return 'updated leader lock during ' + self._async_executor.scheduled_action
|
||||
elif not self.state_handler.bootstrapping:
|
||||
elif not self.state_handler.bootstrapping and not self.is_paused():
|
||||
# Don't have lock, make sure we are not promoting or starting up a master in the background
|
||||
if self._async_executor.scheduled_action == 'promote':
|
||||
with self._async_response:
|
||||
@@ -1206,9 +1221,6 @@ class Ha(object):
|
||||
self._delete_leader()
|
||||
return 'removed leader key after trying and failing to start postgres'
|
||||
return 'failed to start postgres'
|
||||
self._crash_recovery_executed = False
|
||||
if self._rewind.executed and not self._rewind.failed:
|
||||
self._rewind.reset_state()
|
||||
return None
|
||||
|
||||
def cancel_initialization(self):
|
||||
@@ -1240,7 +1252,6 @@ class Ha(object):
|
||||
self.cancel_initialization()
|
||||
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
||||
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
|
||||
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
|
||||
self.dcs.take_leader()
|
||||
self.set_is_leader(True)
|
||||
self.state_handler.call_nowait(ACTION_ON_START)
|
||||
@@ -1296,8 +1307,12 @@ class Ha(object):
|
||||
def _run_cycle(self):
|
||||
dcs_failed = False
|
||||
try:
|
||||
self.state_handler.reset_cluster_info_state()
|
||||
self.load_cluster_from_dcs()
|
||||
try:
|
||||
self.load_cluster_from_dcs()
|
||||
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover)
|
||||
except Exception:
|
||||
self.state_handler.reset_cluster_info_state(None, self.patroni.nofailover)
|
||||
raise
|
||||
|
||||
if self.is_paused():
|
||||
self.watchdog.disable()
|
||||
@@ -1329,12 +1344,24 @@ class Ha(object):
|
||||
if self.state_handler.bootstrapping:
|
||||
return self.post_bootstrap()
|
||||
|
||||
if self.recovering and not self._rewind.is_needed:
|
||||
if self.recovering:
|
||||
self.recovering = False
|
||||
# Check if we tried to recover and failed
|
||||
msg = self.post_recover()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
if not self._rewind.is_needed:
|
||||
# Check if we tried to recover from postgres crash and failed
|
||||
msg = self.post_recover()
|
||||
if msg is not None:
|
||||
return msg
|
||||
|
||||
# Reset some states after postgres successfully started up
|
||||
self._crash_recovery_executed = False
|
||||
if self._rewind.executed and not self._rewind.failed:
|
||||
self._rewind.reset_state()
|
||||
|
||||
# The Raft cluster without a quorum takes a bit of time to stabilize.
|
||||
# Therefore we want to postpone the leader race if we just started up.
|
||||
if self.cluster.is_unlocked() and self.dcs.__class__.__name__ == 'Raft':
|
||||
return 'started as a secondary'
|
||||
|
||||
# is data directory empty?
|
||||
if self.state_handler.data_directory_empty():
|
||||
@@ -1348,6 +1375,8 @@ class Ha(object):
|
||||
self.release_leader_key_voluntarily()
|
||||
return 'released leader key voluntarily as data dir empty and currently leader'
|
||||
|
||||
if self.is_paused():
|
||||
return 'running with empty data directory'
|
||||
return self.bootstrap() # new node
|
||||
else:
|
||||
# check if we are allowed to join
|
||||
@@ -1359,9 +1388,16 @@ class Ha(object):
|
||||
|
||||
if self.sysid_valid(self.cluster.initialize):
|
||||
if self.cluster.initialize != data_sysid:
|
||||
logger.fatal("system ID mismatch, node %s belongs to a different cluster: %s != %s",
|
||||
self.state_handler.name, self.cluster.initialize, data_sysid)
|
||||
sys.exit(1)
|
||||
if self.is_paused():
|
||||
logger.warning('system ID has changed while in paused mode. Patroni will exit when resuming'
|
||||
' unless system ID is reset: %s != %s', self.cluster.initialize, data_sysid)
|
||||
if self.has_lock():
|
||||
self.release_leader_key_voluntarily()
|
||||
return 'released leader key voluntarily due to the system ID mismatch'
|
||||
else:
|
||||
logger.fatal('system ID mismatch, node %s belongs to a different cluster: %s != %s',
|
||||
self.state_handler.name, self.cluster.initialize, data_sysid)
|
||||
sys.exit(1)
|
||||
elif self.cluster.is_unlocked() and not self.is_paused():
|
||||
# "bootstrap", but data directory is not empty
|
||||
if not self.state_handler.cb_called and self.state_handler.is_running() \
|
||||
@@ -1394,20 +1430,28 @@ class Ha(object):
|
||||
|
||||
try:
|
||||
if self.cluster.is_unlocked():
|
||||
return self.process_unhealthy_cluster()
|
||||
ret = self.process_unhealthy_cluster()
|
||||
else:
|
||||
msg = self.process_healthy_cluster()
|
||||
return self.evaluate_scheduled_restart() or msg
|
||||
ret = self.evaluate_scheduled_restart() or msg
|
||||
finally:
|
||||
# we might not have a valid PostgreSQL connection here if another thread
|
||||
# stops PostgreSQL, therefore, we only reload replication slots if no
|
||||
# asynchronous processes are running (should be always the case for the master)
|
||||
if not self._async_executor.busy and not self.state_handler.is_starting():
|
||||
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
|
||||
create_slots = self.state_handler.slots_handler.sync_replication_slots(self.cluster,
|
||||
self.patroni.nofailover)
|
||||
if not self.state_handler.cb_called:
|
||||
if not self.state_handler.is_leader():
|
||||
self._rewind.trigger_check_diverged_lsn()
|
||||
self.state_handler.call_nowait(ACTION_ON_START)
|
||||
if create_slots and self.cluster.leader:
|
||||
err = self._async_executor.try_run_async('copy_logical_slots',
|
||||
self.state_handler.slots_handler.copy_logical_slots,
|
||||
args=(self.cluster.leader, create_slots))
|
||||
if not err:
|
||||
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
|
||||
return ret
|
||||
except DCSError:
|
||||
dcs_failed = True
|
||||
logger.error('Error communicating with DCS')
|
||||
@@ -1438,7 +1482,7 @@ class Ha(object):
|
||||
self.watchdog.disable()
|
||||
elif not self._join_aborted:
|
||||
# FIXME: If stop doesn't reach safepoint quickly enough keepalive is triggered. If shutdown checkpoint
|
||||
# takes longer than ttl, then leader key is lost and replication might not have sent out all xlog.
|
||||
# takes longer than ttl, then leader key is lost and replication might not have sent out all WAL.
|
||||
# This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data.
|
||||
# We probably need to something smarter here.
|
||||
disable_wd = self.watchdog.disable if self.watchdog.is_running else None
|
||||
|
||||
+14
-1
@@ -166,6 +166,8 @@ class PatroniLogger(Thread):
|
||||
self._root_logger.addHandler(self._queue_handler)
|
||||
self._root_logger.removeHandler(self._proxy_handler)
|
||||
|
||||
prev_record = None
|
||||
|
||||
while True:
|
||||
self._close_old_handlers()
|
||||
|
||||
@@ -173,7 +175,18 @@ class PatroniLogger(Thread):
|
||||
if record is None:
|
||||
break
|
||||
|
||||
self.log_handler.handle(record)
|
||||
if self._root_logger.level == logging.INFO:
|
||||
if record.msg.startswith('Lock owner: '):
|
||||
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')):
|
||||
self.log_handler.handle(prev_record)
|
||||
prev_record = None
|
||||
|
||||
if record:
|
||||
self.log_handler.handle(record)
|
||||
|
||||
self._queue_handler.queue.task_done()
|
||||
|
||||
def shutdown(self):
|
||||
|
||||
+151
-50
@@ -1,28 +1,31 @@
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import six
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dateutil import tz
|
||||
from datetime import datetime
|
||||
from patroni.postgresql.callback_executor import CallbackExecutor
|
||||
from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.cancellable import CancellableSubprocess
|
||||
from patroni.postgresql.config import ConfigHandler, mtime
|
||||
from patroni.postgresql.connection import Connection, get_connection_cursor
|
||||
from patroni.postgresql.misc import parse_history, parse_lsn, postgres_major_version_to_int
|
||||
from patroni.postgresql.postmaster import PostmasterProcess
|
||||
from patroni.postgresql.slots import SlotsHandler
|
||||
from patroni.exceptions import PostgresConnectionException
|
||||
from patroni.utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
||||
from dateutil import tz
|
||||
from psutil import TimeoutExpired
|
||||
from threading import current_thread, Lock
|
||||
|
||||
from .bootstrap import Bootstrap
|
||||
from .callback_executor import CallbackExecutor
|
||||
from .cancellable import CancellableSubprocess
|
||||
from .config import ConfigHandler, mtime
|
||||
from .connection import Connection, get_connection_cursor
|
||||
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
|
||||
from .postmaster import PostmasterProcess
|
||||
from .slots import SlotsHandler
|
||||
from ..exceptions import PostgresConnectionException
|
||||
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,13 +51,13 @@ def null_context():
|
||||
|
||||
class Postgresql(object):
|
||||
|
||||
POSTMASTER_START_TIME = "pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')"
|
||||
POSTMASTER_START_TIME = "pg_catalog.pg_postmaster_start_time()"
|
||||
TL_LSN = ("CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
|
||||
"ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
|
||||
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, " # master timeline
|
||||
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
|
||||
"ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END, " # write_lsn
|
||||
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, "
|
||||
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, "
|
||||
"pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, "
|
||||
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
|
||||
|
||||
@@ -101,15 +104,19 @@ class Postgresql(object):
|
||||
self._state_entry_timestamp = None
|
||||
|
||||
self._cluster_info_state = {}
|
||||
self._has_permanent_logical_slots = True
|
||||
self._enforce_hot_standby_feedback = False
|
||||
self._cached_replica_timeline = None
|
||||
|
||||
# Last known running process
|
||||
self._postmaster_proc = None
|
||||
|
||||
if self.is_running():
|
||||
if self.is_running(): # we are "joining" already running postgres
|
||||
self.set_state('running')
|
||||
self.set_role('master' if self.is_leader() else 'replica')
|
||||
self.config.write_postgresql_conf() # we are "joining" already running postgres
|
||||
# postpone writing postgresql.conf for 12+ because recovery parameters are not yet known
|
||||
if self.major_version < 120000 or self.is_leader():
|
||||
self.config.write_postgresql_conf()
|
||||
hba_saved = self.config.replace_pg_hba()
|
||||
ident_saved = self.config.replace_pg_ident()
|
||||
if hba_saved or ident_saved:
|
||||
@@ -152,14 +159,18 @@ class Postgresql(object):
|
||||
@property
|
||||
def cluster_info_query(self):
|
||||
if self._major_version >= 90600:
|
||||
extra = "(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, " +\
|
||||
"plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" + \
|
||||
" AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"\
|
||||
if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL"
|
||||
extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END,"
|
||||
" slot_name, conninfo FROM pg_catalog.pg_stat_get_wal_receiver()")
|
||||
" slot_name, conninfo, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra)
|
||||
if self.role == 'standby_leader':
|
||||
extra = "timeline_id" + extra + ", pg_catalog.pg_control_checkpoint()"
|
||||
else:
|
||||
extra = "0" + extra
|
||||
else:
|
||||
extra = "0, NULL, NULL, NULL"
|
||||
extra = "0, NULL, NULL, NULL, NULL"
|
||||
|
||||
return ("SELECT " + self.TL_LSN + ", {2}").format(self.wal_name, self.lsn_name, extra)
|
||||
|
||||
@@ -299,16 +310,36 @@ class Postgresql(object):
|
||||
replica_methods = self.create_replica_methods
|
||||
return any(self.replica_method_can_work_without_replication_connection(m) for m in replica_methods)
|
||||
|
||||
def reset_cluster_info_state(self):
|
||||
@property
|
||||
def enforce_hot_standby_feedback(self):
|
||||
return self._enforce_hot_standby_feedback
|
||||
|
||||
def set_enforce_hot_standby_feedback(self, value):
|
||||
# If we enable or disable the hot_standby_feedback we need to update postgresql.conf and reload
|
||||
if self._enforce_hot_standby_feedback != value:
|
||||
self._enforce_hot_standby_feedback = value
|
||||
if self.is_running():
|
||||
self.config.write_postgresql_conf()
|
||||
self.reload()
|
||||
|
||||
def reset_cluster_info_state(self, cluster, nofailover=None):
|
||||
self._cluster_info_state = {}
|
||||
if cluster and cluster.config and cluster.config.modify_index:
|
||||
self._has_permanent_logical_slots =\
|
||||
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
|
||||
self.set_enforce_hot_standby_feedback(
|
||||
self._has_permanent_logical_slots or
|
||||
cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
|
||||
|
||||
def _cluster_info_state_get(self, name):
|
||||
if not self._cluster_info_state:
|
||||
try:
|
||||
result = self._is_leader_retry(self._query, self.cluster_info_query).fetchone()
|
||||
self._cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
|
||||
'received_location', 'replay_paused', 'pg_control_timeline',
|
||||
'received_tli', 'slot_name', 'conninfo'], result))
|
||||
cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
|
||||
'received_location', 'replay_paused', 'pg_control_timeline',
|
||||
'received_tli', 'slot_name', 'conninfo', 'slots'], result))
|
||||
cluster_info_state['slots'] = self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
|
||||
self._cluster_info_state = cluster_info_state
|
||||
except RetryFailedError as e: # SELECT failed two times
|
||||
self._cluster_info_state = {'error': str(e)}
|
||||
if not self.is_starting() and self.pg_isready() == STATE_REJECT:
|
||||
@@ -325,6 +356,9 @@ class Postgresql(object):
|
||||
def received_location(self):
|
||||
return self._cluster_info_state_get('received_location')
|
||||
|
||||
def slots(self):
|
||||
return self._cluster_info_state_get('slots')
|
||||
|
||||
def primary_slot_name(self):
|
||||
return self._cluster_info_state_get('slot_name')
|
||||
|
||||
@@ -337,22 +371,58 @@ class Postgresql(object):
|
||||
def is_leader(self):
|
||||
return bool(self._cluster_info_state_get('timeline'))
|
||||
|
||||
def replay_paused(self):
|
||||
return self._cluster_info_state_get('replay_paused')
|
||||
|
||||
def resume_wal_replay(self):
|
||||
self._query('SELECT pg_catalog.pg_{0}_replay_resume()'.format(self.wal_name))
|
||||
|
||||
def handle_parameter_change(self):
|
||||
if self.major_version >= 140000 and self.replay_paused():
|
||||
logger.info('Resuming paused WAL replay for PostgreSQL 14+')
|
||||
self.resume_wal_replay()
|
||||
|
||||
def pg_control_timeline(self):
|
||||
try:
|
||||
return int(self.controldata().get("Latest checkpoint's TimeLineID"))
|
||||
except (TypeError, ValueError):
|
||||
logger.exception('Failed to parse timeline from pg_controldata output')
|
||||
|
||||
def parse_wal_record(self, timeline, lsn):
|
||||
out, err = self.waldump(timeline, lsn, 1)
|
||||
if out and not err:
|
||||
match = re.match(r'^rmgr:\s+(.+?)\s+len \(rec/tot\):\s+\d+/\s+\d+, tx:\s+\d+, '
|
||||
r'lsn: ([0-9A-Fa-f]+/[0-9A-Fa-f]+), prev ([0-9A-Fa-f]+/[0-9A-Fa-f]+), '
|
||||
r'.*?desc: (.+)', out.decode('utf-8'))
|
||||
if match:
|
||||
return match.groups()
|
||||
return None, None, None, None
|
||||
|
||||
def latest_checkpoint_location(self):
|
||||
"""Returns checkpoint location for the cleanly shut down primary"""
|
||||
"""Returns checkpoint location for the cleanly shut down primary.
|
||||
But, if we know that the checkpoint was written to the new WAL
|
||||
due to the archive_mode=on, we will return the LSN of prev wal record (SWITCH)."""
|
||||
|
||||
data = self.controldata()
|
||||
lsn = data.get('Latest checkpoint location')
|
||||
if data.get('Database cluster state') == 'shut down' and lsn:
|
||||
timeline = data.get("Latest checkpoint's TimeLineID")
|
||||
lsn = checkpoint_lsn = data.get('Latest checkpoint location')
|
||||
if data.get('Database cluster state') == 'shut down' and lsn and timeline:
|
||||
try:
|
||||
return str(parse_lsn(lsn))
|
||||
except (IndexError, ValueError) as e:
|
||||
logger.error('Exception when parsing lsn %s: %r', lsn, e)
|
||||
checkpoint_lsn = parse_lsn(checkpoint_lsn)
|
||||
rm_name, lsn, prev, desc = self.parse_wal_record(timeline, lsn)
|
||||
desc = desc.strip().lower()
|
||||
if rm_name == 'XLOG' and parse_lsn(lsn) == checkpoint_lsn and prev and\
|
||||
desc.startswith('checkpoint') and desc.endswith('shutdown'):
|
||||
_, lsn, _, desc = self.parse_wal_record(timeline, prev)
|
||||
prev = parse_lsn(prev)
|
||||
# If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint.
|
||||
# In this case we want to take the LSN of previous record (switch) as the last known WAL location.
|
||||
if parse_lsn(lsn) == prev and desc.strip() in ('xlog switch', 'SWITCH'):
|
||||
return str(prev)
|
||||
except Exception as e:
|
||||
logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e)
|
||||
if isinstance(checkpoint_lsn, six.integer_types):
|
||||
return str(checkpoint_lsn)
|
||||
|
||||
def is_running(self):
|
||||
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
|
||||
@@ -362,7 +432,7 @@ class Postgresql(object):
|
||||
return self._postmaster_proc
|
||||
self._postmaster_proc = None
|
||||
|
||||
# we noticed that postgres was restarted, force syncing of replication
|
||||
# we noticed that postgres was restarted, force syncing of replication slots and check of logical slots
|
||||
self.slots_handler.schedule()
|
||||
|
||||
self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir)
|
||||
@@ -459,6 +529,8 @@ class Postgresql(object):
|
||||
self._pending_restart = False
|
||||
|
||||
try:
|
||||
if not self._major_version:
|
||||
self.configure_server_parameters()
|
||||
configuration = self.config.effective_configuration
|
||||
except Exception:
|
||||
return None
|
||||
@@ -698,7 +770,7 @@ class Postgresql(object):
|
||||
return True
|
||||
|
||||
def get_guc_value(self, name):
|
||||
cmd = [self.pgcommand('postgres'), self._data_dir, '-C', name]
|
||||
cmd = [self.pgcommand('postgres'), '-D', self._data_dir, '-C', name]
|
||||
try:
|
||||
data = subprocess.check_output(cmd)
|
||||
if data:
|
||||
@@ -722,6 +794,20 @@ class Postgresql(object):
|
||||
logger.exception("Error when calling pg_controldata")
|
||||
return {}
|
||||
|
||||
def waldump(self, timeline, lsn, limit):
|
||||
cmd = self.pgcommand('pg_{0}dump'.format(self.wal_name))
|
||||
env = os.environ.copy()
|
||||
env.update(LANG='C', LC_ALL='C', PGDATA=self._data_dir)
|
||||
try:
|
||||
waldump = subprocess.Popen([cmd, '-t', str(timeline), '-s', str(lsn), '-n', str(limit)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
out, err = waldump.communicate()
|
||||
waldump.wait()
|
||||
return out, err
|
||||
except Exception as e:
|
||||
logger.error('Failed to execute `%s -t %s -s %s -n %s`: %r', cmd, timeline, lsn, limit, e)
|
||||
return None, None
|
||||
|
||||
@contextmanager
|
||||
def get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs):
|
||||
conn_kwargs = self.config.replication.copy()
|
||||
@@ -757,6 +843,7 @@ class Postgresql(object):
|
||||
if history[-1][0] == timeline - 1:
|
||||
history_mtime = datetime.fromtimestamp(history_mtime).replace(tzinfo=tz.tzlocal())
|
||||
history[-1].append(history_mtime.isoformat())
|
||||
history[-1].append(self.name)
|
||||
return history
|
||||
except Exception:
|
||||
logger.exception('Failed to read and parse %s', (history_path,))
|
||||
@@ -810,7 +897,7 @@ class Postgresql(object):
|
||||
logger.info('pre_promote script `%s` exited with %s', cmd, ret)
|
||||
return ret == 0
|
||||
|
||||
def promote(self, wait_seconds, task, on_success=None, access_is_restricted=False):
|
||||
def promote(self, wait_seconds, task, on_success=None):
|
||||
if self.role == 'master':
|
||||
return True
|
||||
|
||||
@@ -827,13 +914,14 @@ class Postgresql(object):
|
||||
logger.info("PostgreSQL promote cancelled.")
|
||||
return False
|
||||
|
||||
self.slots_handler.on_promote()
|
||||
|
||||
ret = self.pg_ctl('promote', '-W')
|
||||
if ret:
|
||||
self.set_role('master')
|
||||
if on_success is not None:
|
||||
on_success()
|
||||
if not access_is_restricted:
|
||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||
ret = self._wait_promote(wait_seconds)
|
||||
return ret
|
||||
|
||||
@@ -863,16 +951,16 @@ class Postgresql(object):
|
||||
try:
|
||||
query = "SELECT " + self.POSTMASTER_START_TIME
|
||||
if current_thread().ident == self.__thread_ident:
|
||||
return self.query(query).fetchone()[0]
|
||||
return self.query(query).fetchone()[0].isoformat(sep=' ')
|
||||
with self.connection().cursor() as cursor:
|
||||
cursor.execute(query)
|
||||
return cursor.fetchone()[0]
|
||||
return cursor.fetchone()[0].isoformat(sep=' ')
|
||||
except psycopg2.Error:
|
||||
return None
|
||||
|
||||
def last_operation(self):
|
||||
return str(self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position'),
|
||||
self.received_location(), self.replayed_location()))
|
||||
return self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position'),
|
||||
self.received_location(), self.replayed_location())
|
||||
|
||||
def configure_server_parameters(self):
|
||||
self._major_version = self.get_major_version()
|
||||
@@ -913,7 +1001,7 @@ class Postgresql(object):
|
||||
new_name = '{0}_{1}'.format(pg_wal_realpath, postfix)
|
||||
os.rename(pg_wal_realpath, new_name)
|
||||
os.unlink(source)
|
||||
os.symlink(source, new_name)
|
||||
os.symlink(new_name, source)
|
||||
|
||||
# Move user defined tablespace directory
|
||||
for (source, pg_tsp_rpath) in self.pg_tblspc_realpaths().items():
|
||||
@@ -921,7 +1009,7 @@ class Postgresql(object):
|
||||
new_name = '{0}_{1}'.format(pg_tsp_rpath, postfix)
|
||||
os.rename(pg_tsp_rpath, new_name)
|
||||
os.unlink(source)
|
||||
os.symlink(source, new_name)
|
||||
os.symlink(new_name, source)
|
||||
|
||||
new_name = '{0}_{1}'.format(self._data_dir, postfix)
|
||||
logger.info('renaming data directory to %s', new_name)
|
||||
@@ -960,11 +1048,15 @@ class Postgresql(object):
|
||||
def _get_synchronous_commit_param(self):
|
||||
return self.query("SHOW synchronous_commit").fetchone()[0]
|
||||
|
||||
def pick_synchronous_standby(self, cluster, sync_node_count=1):
|
||||
def pick_synchronous_standby(self, cluster, sync_node_count=1, sync_node_maxlag=-1):
|
||||
"""Finds the best candidate to be the synchronous standby.
|
||||
|
||||
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
||||
synchronous standby any longer.
|
||||
Parameter sync_node_maxlag(maximum_lag_on_syncnode) would help swapping unhealthy sync replica in case
|
||||
if it stops responding (or hung). Please set the value high enough so it won't unncessarily swap sync
|
||||
standbys during high loads. Any less or equal of 0 value keep the behavior backward compatible and
|
||||
will not swap. Please note that it will not also swap sync standbys in case where all replicas are hung.
|
||||
|
||||
:returns tuple of candidates list and synchronous standby list.
|
||||
"""
|
||||
@@ -973,6 +1065,7 @@ class Postgresql(object):
|
||||
members = {m.name.lower(): m for m in cluster.members}
|
||||
candidates = []
|
||||
sync_nodes = []
|
||||
replica_list = []
|
||||
# Pick candidates based on who has higher replay/remote_write/flush lsn.
|
||||
sync_commit_par = self._get_synchronous_commit_param()
|
||||
sort_col = {'remote_apply': 'replay', 'remote_write': 'write'}.get(sync_commit_par, 'flush')
|
||||
@@ -982,17 +1075,22 @@ class Postgresql(object):
|
||||
# receiving changes faster than the sync member (very rare but possible). Such cases would
|
||||
# trigger sync standby member swapping frequently and the sort on sync_state desc should
|
||||
# help in keeping the query result consistent.
|
||||
for app_name, state, sync_state in self.query(
|
||||
"SELECT pg_catalog.lower(application_name), state, sync_state"
|
||||
for app_name, sync_state, replica_lsn in self.query(
|
||||
"SELECT pg_catalog.lower(application_name), sync_state, pg_{2}_{1}_diff({0}_{1}, '0/0')::bigint"
|
||||
" FROM pg_catalog.pg_stat_replication"
|
||||
" WHERE state = 'streaming'"
|
||||
" ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name)):
|
||||
" WHERE state = 'streaming' AND {0}_{1} IS NOT NULL"
|
||||
" ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name, self.wal_name)):
|
||||
member = members.get(app_name)
|
||||
if not member or member.tags.get('nosync', False):
|
||||
continue
|
||||
candidates.append(member.name)
|
||||
if sync_state == 'sync':
|
||||
sync_nodes.append(member.name)
|
||||
if member and not member.tags.get('nosync', False):
|
||||
replica_list.append((member.name, sync_state, replica_lsn))
|
||||
|
||||
max_lsn = max(replica_list, key=lambda x: x[2])[2] if len(replica_list) > 1 else int(str(self.last_operation()))
|
||||
|
||||
for app_name, sync_state, replica_lsn in replica_list:
|
||||
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
|
||||
candidates.append(app_name)
|
||||
if sync_state == 'sync':
|
||||
sync_nodes.append(app_name)
|
||||
if len(candidates) >= sync_node_count:
|
||||
break
|
||||
|
||||
@@ -1001,8 +1099,11 @@ class Postgresql(object):
|
||||
def schedule_sanity_checks_after_pause(self):
|
||||
"""
|
||||
After coming out of pause we have to:
|
||||
1. sync replication slots, because it might happen that slots were removed
|
||||
2. get new 'Database system identifier' to make sure that it wasn't changed
|
||||
1. configure server parameters if necessary
|
||||
2. sync replication slots, because it might happen that slots were removed
|
||||
3. get new 'Database system identifier' to make sure that it wasn't changed
|
||||
"""
|
||||
if not self._major_version:
|
||||
self.configure_server_parameters()
|
||||
self.slots_handler.schedule()
|
||||
self._sysid = None
|
||||
|
||||
@@ -53,7 +53,7 @@ class Bootstrap(object):
|
||||
error_handler('Error when parsing {0} option {1}: value should be string value'
|
||||
' or a single key-value pair'.format(tool, opt))
|
||||
else:
|
||||
error_handler('{0} options must be list ot dict'.format(tool))
|
||||
error_handler('{0} options must be list or dict'.format(tool))
|
||||
return user_options
|
||||
|
||||
def _initdb(self, config):
|
||||
@@ -90,8 +90,8 @@ class Bootstrap(object):
|
||||
self._postgresql.configure_server_parameters()
|
||||
|
||||
# make sure there is no trigger file or postgres will be automatically promoted
|
||||
trigger_file = 'promote_trigger_file' if self._postgresql.major_version >= 120000 else 'trigger_file'
|
||||
trigger_file = self._postgresql.config.get('recovery_conf', {}).get(trigger_file) or 'promote'
|
||||
trigger_file = self._postgresql.config.triggerfile_good_name
|
||||
trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote'
|
||||
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
|
||||
if os.path.exists(trigger_file):
|
||||
os.unlink(trigger_file)
|
||||
@@ -130,7 +130,7 @@ class Bootstrap(object):
|
||||
# (pghost empty or the default socket directory) connections coming from the local machine.
|
||||
r['host'] = 'localhost' # set it to localhost to write into pgpass
|
||||
|
||||
env = self._postgresql.config.write_pgpass(r) if 'password' in r else None
|
||||
env = self._postgresql.config.write_pgpass(r)
|
||||
env['PGOPTIONS'] = '-c synchronous_commit=local'
|
||||
|
||||
try:
|
||||
|
||||
@@ -36,7 +36,7 @@ class CancellableExecutor(object):
|
||||
with self._lock:
|
||||
if self._process is not None and self._process.is_running() and not self._process_children:
|
||||
try:
|
||||
self._process.suspend() # Suspend the process before getting list of childrens
|
||||
self._process.suspend() # Suspend the process before getting list of children
|
||||
except psutil.Error as e:
|
||||
logger.info('Failed to suspend the process: %s', e.msg)
|
||||
|
||||
@@ -114,7 +114,7 @@ class CancellableSubprocess(CancellableExecutor):
|
||||
with self._lock:
|
||||
return self._is_cancelled
|
||||
|
||||
def cancel(self):
|
||||
def cancel(self, kill=False):
|
||||
with self._lock:
|
||||
self._is_cancelled = True
|
||||
if self._process is None or not self._process.is_running():
|
||||
@@ -127,5 +127,7 @@ class CancellableSubprocess(CancellableExecutor):
|
||||
with self._lock:
|
||||
if self._process is None or not self._process.is_running():
|
||||
return
|
||||
if kill:
|
||||
break
|
||||
|
||||
self._kill_process()
|
||||
|
||||
@@ -336,8 +336,9 @@ class ConfigHandler(object):
|
||||
if "stats_temp_directory" in self._server_parameters:
|
||||
self.try_to_create_dir(self._server_parameters["stats_temp_directory"],
|
||||
"'{}' is defined in stats_temp_directory, {}")
|
||||
self.try_to_create_dir(os.path.dirname(self._pgpass),
|
||||
"'{}' is defined in `postgresql.pgpass`, {}")
|
||||
if not self._krbsrvname:
|
||||
self.try_to_create_dir(os.path.dirname(self._pgpass),
|
||||
"'{}' is defined in `postgresql.pgpass`, {}")
|
||||
|
||||
@property
|
||||
def _configuration_to_save(self):
|
||||
@@ -352,7 +353,7 @@ class ConfigHandler(object):
|
||||
|
||||
def save_configuration_files(self, check_custom_bootstrap=False):
|
||||
"""
|
||||
copy postgresql.conf to postgresql.conf.backup to be able to retrive configuration files
|
||||
copy postgresql.conf to postgresql.conf.backup to be able to retrieve configuration files
|
||||
- originally stored as symlinks, those are normally skipped by pg_basebackup
|
||||
- in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239)
|
||||
"""
|
||||
@@ -387,17 +388,22 @@ class ConfigHandler(object):
|
||||
if 'custom_conf' not in self._config and not os.path.exists(self._postgresql_base_conf):
|
||||
os.rename(self._postgresql_conf, self._postgresql_base_conf)
|
||||
|
||||
configuration = configuration or self._server_parameters.copy()
|
||||
# Due to the permanent logical replication slots configured we have to enable hot_standby_feedback
|
||||
if self._postgresql.enforce_hot_standby_feedback:
|
||||
configuration['hot_standby_feedback'] = 'on'
|
||||
|
||||
with ConfigWriter(self._postgresql_conf) as f:
|
||||
include = self._config.get('custom_conf') or self._postgresql_base_conf_name
|
||||
f.writeline("include '{0}'\n".format(ConfigWriter.escape(include)))
|
||||
for name, value in sorted((configuration or self._server_parameters).items()):
|
||||
for name, value in sorted((configuration).items()):
|
||||
value = transform_postgresql_parameter_value(self._postgresql.major_version, name, value)
|
||||
if (not self._postgresql.bootstrap.running_custom_bootstrap or name != 'hba_file') \
|
||||
and name not in self._RECOVERY_PARAMETERS and value is not None:
|
||||
if value is not None and\
|
||||
(name != 'hba_file' or not self._postgresql.bootstrap.running_custom_bootstrap):
|
||||
f.write_param(name, value)
|
||||
# when we are doing custom bootstrap we assume that we don't know superuser password
|
||||
# and in order to be able to change it, we are opening trust access from a certain address
|
||||
# therefore we need to make sure that hba_file is not overriden
|
||||
# therefore we need to make sure that hba_file is not overridden
|
||||
# after changing superuser password we will "revert" all these "changes"
|
||||
if self._postgresql.bootstrap.running_custom_bootstrap or 'hba_file' not in self._server_parameters:
|
||||
f.write_param('hba_file', self._pg_hba_conf)
|
||||
@@ -478,7 +484,7 @@ class ConfigHandler(object):
|
||||
def format_dsn(self, params, include_dbname=False):
|
||||
# A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq
|
||||
keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port',
|
||||
'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslrootcert', 'sslcrl',
|
||||
'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl',
|
||||
'application_name', 'krbsrvname', 'gssencmode', 'channel_binding')
|
||||
if include_dbname:
|
||||
params = params.copy()
|
||||
@@ -548,7 +554,7 @@ class ConfigHandler(object):
|
||||
return os.path.exists(self._recovery_conf)
|
||||
|
||||
@property
|
||||
def _triggerfile_good_name(self):
|
||||
def triggerfile_good_name(self):
|
||||
return 'trigger_file' if self._postgresql.major_version < 120000 else 'promote_trigger_file'
|
||||
|
||||
@property
|
||||
@@ -696,7 +702,9 @@ class ConfigHandler(object):
|
||||
if wal_receiver_primary_slot_name is not None:
|
||||
self._current_recovery_params['primary_slot_name'][0] = wal_receiver_primary_slot_name
|
||||
|
||||
required = {'restart': 0, 'reload': 0}
|
||||
# Increment the 'reload' to enforce write of postgresql.conf when joining the running postgres
|
||||
required = {'restart': 0,
|
||||
'reload': int(not self._postgresql.cb_called and self._postgresql.major_version >= 120000)}
|
||||
|
||||
def record_missmatch(mtype):
|
||||
required['restart' if mtype else 'reload'] += 1
|
||||
@@ -751,13 +759,13 @@ class ConfigHandler(object):
|
||||
return env
|
||||
|
||||
def write_recovery_conf(self, recovery_params):
|
||||
self._recovery_params = recovery_params
|
||||
if self._postgresql.major_version >= 120000:
|
||||
if parse_bool(recovery_params.pop('standby_mode', None)):
|
||||
open(self._standby_signal, 'w').close()
|
||||
else:
|
||||
self._remove_file_if_exists(self._standby_signal)
|
||||
open(self._recovery_signal, 'w').close()
|
||||
self._recovery_params = recovery_params
|
||||
else:
|
||||
with ConfigWriter(self._recovery_conf) as f:
|
||||
os.chmod(self._recovery_conf, stat.S_IWRITE | stat.S_IREAD)
|
||||
@@ -801,8 +809,8 @@ class ConfigHandler(object):
|
||||
|
||||
if self.get('recovery_conf'):
|
||||
value = self._config['recovery_conf'].pop(self._triggerfile_wrong_name, None)
|
||||
if self._triggerfile_good_name not in self._config['recovery_conf'] and value:
|
||||
self._config['recovery_conf'][self._triggerfile_good_name] = value
|
||||
if self.triggerfile_good_name not in self._config['recovery_conf'] and value:
|
||||
self._config['recovery_conf'][self.triggerfile_good_name] = value
|
||||
|
||||
def get_server_parameters(self, config):
|
||||
parameters = config['parameters'].copy()
|
||||
@@ -871,25 +879,21 @@ class ConfigHandler(object):
|
||||
def resolve_connection_addresses(self):
|
||||
port = self._server_parameters['port']
|
||||
tcp_local_address = self._get_tcp_local_address()
|
||||
|
||||
local_address = {'port': port}
|
||||
if self._config.get('use_unix_socket'):
|
||||
unix_socket_directories = self._server_parameters.get('unix_socket_directories')
|
||||
if unix_socket_directories is not None:
|
||||
# fallback to tcp if unix_socket_directories is set, but there are no sutable values
|
||||
local_address['host'] = self._get_unix_local_address(unix_socket_directories) or tcp_local_address
|
||||
|
||||
# if unix_socket_directories is not specified, but use_unix_socket is set to true - do our best
|
||||
# to use default value, i.e. don't specify a host neither in connection url nor arguments
|
||||
else:
|
||||
local_address['host'] = tcp_local_address
|
||||
|
||||
self._local_address = local_address
|
||||
self.local_replication_address = {'host': tcp_local_address, 'port': port}
|
||||
|
||||
netloc = self._config.get('connect_address') or tcp_local_address + ':' + port
|
||||
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
|
||||
|
||||
unix_local_address = {'port': port}
|
||||
unix_socket_directories = self._server_parameters.get('unix_socket_directories')
|
||||
if unix_socket_directories is not None:
|
||||
# fallback to tcp if unix_socket_directories is set, but there are no suitable values
|
||||
unix_local_address['host'] = self._get_unix_local_address(unix_socket_directories) or tcp_local_address
|
||||
|
||||
tcp_local_address = {'host': tcp_local_address, 'port': port}
|
||||
|
||||
self._local_address = unix_local_address if self._config.get('use_unix_socket') else tcp_local_address
|
||||
self.local_replication_address = unix_local_address\
|
||||
if self._config.get('use_unix_socket_repl') else tcp_local_address
|
||||
|
||||
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
|
||||
self._postgresql.set_connection_kwargs(self.local_connect_kwargs)
|
||||
|
||||
def _get_pg_settings(self, names):
|
||||
@@ -997,8 +1001,9 @@ class ConfigHandler(object):
|
||||
if self._postgresql.major_version >= 90500:
|
||||
time.sleep(1)
|
||||
try:
|
||||
pending_restart = self._postgresql.query('SELECT COUNT(*) FROM pg_catalog.pg_settings'
|
||||
' WHERE pending_restart').fetchone()[0] > 0
|
||||
pending_restart = self._postgresql.query(
|
||||
'SELECT COUNT(*) FROM pg_catalog.pg_settings WHERE pg_catalog.lower(name) != ALL(%s)'
|
||||
' AND pending_restart', [n.lower() for n in self._RECOVERY_PARAMETERS]).fetchone()[0] > 0
|
||||
self._postgresql.set_pending_restart(pending_restart)
|
||||
except Exception as e:
|
||||
logger.warning('Exception %r when running query', e)
|
||||
@@ -1062,6 +1067,14 @@ class ConfigHandler(object):
|
||||
if cvalue > value:
|
||||
effective_configuration[name] = cvalue
|
||||
self._postgresql.set_pending_restart(True)
|
||||
|
||||
# If we are using custom bootstrap with PITR it could fail when values
|
||||
# like max_connections are increased, therefore we disable hot_standby.
|
||||
if self._postgresql.bootstrap.running_custom_bootstrap and \
|
||||
(self._postgresql.bootstrap.keep_existing_recovery_conf or self._recovery_conf):
|
||||
effective_configuration['hot_standby'] = 'off'
|
||||
self._postgresql.set_pending_restart(True)
|
||||
|
||||
return effective_configuration
|
||||
|
||||
@property
|
||||
|
||||
@@ -40,7 +40,8 @@ class Connection(object):
|
||||
|
||||
@contextmanager
|
||||
def get_connection_cursor(**kwargs):
|
||||
with psycopg2.connect(**kwargs) as conn:
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
conn = psycopg2.connect(**kwargs)
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
yield cur
|
||||
conn.close()
|
||||
|
||||
@@ -37,7 +37,7 @@ def postgres_version_to_int(pg_version):
|
||||
raise PostgresException('Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: {0}'.format(pg_version))
|
||||
|
||||
if len(components) == 2:
|
||||
# new style verion numbers, i.e. 10.1 becomes 100001
|
||||
# new style version numbers, i.e. 10.1 becomes 100001
|
||||
components.insert(1, 0)
|
||||
|
||||
return int(''.join('{0:02d}'.format(c) for c in components))
|
||||
@@ -68,3 +68,8 @@ def parse_history(data):
|
||||
yield values
|
||||
except (IndexError, ValueError):
|
||||
logger.exception('Exception when parsing timeline history line "%s"', values)
|
||||
|
||||
|
||||
def format_lsn(lsn, full=False):
|
||||
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
|
||||
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
|
||||
|
||||
@@ -187,10 +187,10 @@ class PostmasterProcess(psutil.Process):
|
||||
user_backends_cmdlines = []
|
||||
for child in children:
|
||||
try:
|
||||
cmdline = child.cmdline()[0]
|
||||
if not aux_proc_re.match(cmdline):
|
||||
cmdline = child.cmdline()
|
||||
if cmdline and not aux_proc_re.match(cmdline[0]):
|
||||
user_backends.append(child)
|
||||
user_backends_cmdlines.append(cmdline)
|
||||
user_backends_cmdlines.append(cmdline[0])
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
if user_backends:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import six
|
||||
import subprocess
|
||||
|
||||
from threading import Lock, Thread
|
||||
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import parse_history, parse_lsn
|
||||
from .misc import format_lsn, parse_history, parse_lsn
|
||||
from ..async_executor import CriticalTask
|
||||
from ..dcs import Leader
|
||||
|
||||
@@ -16,11 +17,6 @@ REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECKPOINT': 1, 'CHECK': 2, 'NE
|
||||
'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6})
|
||||
|
||||
|
||||
def format_lsn(lsn, full=False):
|
||||
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
|
||||
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
|
||||
|
||||
|
||||
class Rewind(object):
|
||||
|
||||
def __init__(self, postgresql):
|
||||
@@ -58,11 +54,10 @@ class Rewind(object):
|
||||
if self.can_rewind_or_reinitialize_allowed and self._state != REWIND_STATUS.NEED:
|
||||
self._state = REWIND_STATUS.CHECK
|
||||
|
||||
def check_leader_is_not_in_recovery(self, **kwargs):
|
||||
if not kwargs.get('database'):
|
||||
kwargs['database'] = self._postgresql.database
|
||||
@staticmethod
|
||||
def check_leader_is_not_in_recovery(conn_kwargs):
|
||||
try:
|
||||
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **kwargs) as cur:
|
||||
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
|
||||
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||
if not cur.fetchone()[0]:
|
||||
return True
|
||||
@@ -73,23 +68,14 @@ class Rewind(object):
|
||||
def _get_checkpoint_end(self, timeline, lsn):
|
||||
"""The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
|
||||
Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump
|
||||
and parse the output. We are trying to read two records, and expect that it wil fail to read the second one:
|
||||
and parse the output. We are trying to read two records, and expect that it will fail to read the second one:
|
||||
`pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0`
|
||||
The error message contains information about LSN of the next record, which is exactly where checkpoint ends."""
|
||||
|
||||
cmd = self._postgresql.pgcommand('pg_{0}dump'.format(self._postgresql.wal_name))
|
||||
lsn8 = format_lsn(lsn, True)
|
||||
lsn = format_lsn(lsn)
|
||||
env = os.environ.copy()
|
||||
env.update(LANG='C', LC_ALL='C', PGDATA=self._postgresql.data_dir)
|
||||
try:
|
||||
waldump = subprocess.Popen([cmd, '-t', str(timeline), '-s', lsn, '-n', '2'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
out, err = waldump.communicate()
|
||||
waldump.wait()
|
||||
except Exception as e:
|
||||
logger.error('Failed to execute `%s -t %s -s %s -n 2`: %r', cmd, timeline, lsn, e)
|
||||
else:
|
||||
out, err = self._postgresql.waldump(timeline, lsn, 2)
|
||||
if out is not None and err is not None:
|
||||
out = out.decode('utf-8').rstrip().split('\n')
|
||||
err = err.decode('utf-8').rstrip().split('\n')
|
||||
pattern = 'error in WAL record at {0}: invalid record length at '.format(lsn)
|
||||
@@ -102,7 +88,7 @@ class Rewind(object):
|
||||
return parse_lsn(err[0][i:j])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
|
||||
logger.error('Failed to parse `%s -t %s -s %s -n 2` output', cmd, timeline, lsn)
|
||||
logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name)
|
||||
logger.error(' stdout=%s', '\n'.join(out))
|
||||
logger.error(' stderr=%s', '\n'.join(err))
|
||||
|
||||
@@ -164,6 +150,12 @@ class Rewind(object):
|
||||
|
||||
logger.info('master: history=%s', '\n'.join(history_show))
|
||||
|
||||
def _conn_kwargs(self, member, auth):
|
||||
ret = member.conn_kwargs(auth)
|
||||
if not ret.get('database'):
|
||||
ret['database'] = self._postgresql.database
|
||||
return ret
|
||||
|
||||
def _check_timeline_and_lsn(self, leader):
|
||||
in_recovery, local_timeline, local_lsn = self._get_local_timeline_lsn()
|
||||
if local_timeline is None or local_lsn is None:
|
||||
@@ -173,7 +165,7 @@ class Rewind(object):
|
||||
if leader.member.data.get('role') != 'master':
|
||||
return
|
||||
# standby cluster
|
||||
elif not self.check_leader_is_not_in_recovery(**leader.conn_kwargs(self._postgresql.config.replication)):
|
||||
elif not self.check_leader_is_not_in_recovery(self._conn_kwargs(leader, self._postgresql.config.replication)):
|
||||
return
|
||||
|
||||
history = need_rewind = None
|
||||
@@ -188,7 +180,9 @@ class Rewind(object):
|
||||
need_rewind = False
|
||||
elif master_timeline > 1:
|
||||
cur.execute('TIMELINE_HISTORY %s', (master_timeline,))
|
||||
history = bytes(cur.fetchone()[1]).decode('utf-8')
|
||||
history = cur.fetchone()[1]
|
||||
if not isinstance(history, six.string_types):
|
||||
history = bytes(history).decode('utf-8')
|
||||
logger.debug('master: history=%s', history)
|
||||
except Exception:
|
||||
return logger.exception('Exception when working with master via replication connection')
|
||||
@@ -273,7 +267,7 @@ class Rewind(object):
|
||||
i += 1
|
||||
|
||||
logger.info('Trying to fetch the missing wal: %s', cmd)
|
||||
return self._postgresql.cancellable.call(cmd, shell=True) == 0
|
||||
return self._postgresql.cancellable.call(shlex.split(cmd)) == 0
|
||||
|
||||
def _find_missing_wal(self, data):
|
||||
# could not open file "$PGDATA/pg_wal/0000000A00006AA100000068": No such file or directory
|
||||
@@ -332,7 +326,7 @@ class Rewind(object):
|
||||
return logger.warning('Can not run pg_rewind because postgres is still running')
|
||||
|
||||
# prepare pg_rewind connection
|
||||
r = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
|
||||
r = self._conn_kwargs(leader, self._postgresql.config.rewind_credentials)
|
||||
|
||||
# 1. make sure that we are really trying to rewind from the master
|
||||
# 2. make sure that pg_control contains the new timeline by:
|
||||
@@ -340,17 +334,17 @@ class Rewind(object):
|
||||
# waiting until Patroni on the master will expose checkpoint_after_promote=True
|
||||
checkpoint_status = leader.checkpoint_after_promote if isinstance(leader, Leader) else None
|
||||
if checkpoint_status is None: # master still runs the old Patroni
|
||||
leader_status = self._postgresql.checkpoint(leader.conn_kwargs(self._postgresql.config.superuser))
|
||||
leader_status = self._postgresql.checkpoint(self._conn_kwargs(leader, self._postgresql.config.superuser))
|
||||
if leader_status:
|
||||
return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status)
|
||||
elif not checkpoint_status:
|
||||
return logger.info('Waiting for checkpoint on %s before rewind', leader.name)
|
||||
elif not self.check_leader_is_not_in_recovery(**r):
|
||||
elif not self.check_leader_is_not_in_recovery(r):
|
||||
return
|
||||
|
||||
if self.pg_rewind(r):
|
||||
self._state = REWIND_STATUS.SUCCESS
|
||||
elif not self.check_leader_is_not_in_recovery(**r):
|
||||
elif not self.check_leader_is_not_in_recovery(r):
|
||||
logger.warning('Failed to rewind because master %s become unreachable', leader.name)
|
||||
else:
|
||||
logger.error('Failed to rewind from healty master: %s', leader.name)
|
||||
@@ -427,4 +421,10 @@ class Rewind(object):
|
||||
opts = self.read_postmaster_opts()
|
||||
opts.update({'archive_mode': 'on', 'archive_command': 'false'})
|
||||
self._postgresql.config.remove_recovery_conf()
|
||||
return self.single_user_mode(options=opts) == 0 or None
|
||||
output = {}
|
||||
ret = self.single_user_mode(communicate=output, options=opts)
|
||||
if ret != 0:
|
||||
logger.error('Crash recovery finished with code=%s', ret)
|
||||
logger.info(' stdout=%s', output['stdout'].decode('utf-8'))
|
||||
logger.info(' stderr=%s', output['stderr'].decode('utf-8'))
|
||||
return ret == 0 or None
|
||||
|
||||
+251
-52
@@ -1,14 +1,34 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from patroni.postgresql.connection import get_connection_cursor
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from psycopg2.errors import UndefinedFile
|
||||
|
||||
from .connection import get_connection_cursor
|
||||
from .misc import format_lsn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compare_slots(s1, s2):
|
||||
def compare_slots(s1, s2, dbid='database'):
|
||||
return s1['type'] == s2['type'] and (s1['type'] == 'physical' or
|
||||
s1['database'] == s2['database'] and s1['plugin'] == s2['plugin'])
|
||||
s1.get(dbid) == s2.get(dbid) and s1['plugin'] == s2['plugin'])
|
||||
|
||||
|
||||
def fsync_dir(path):
|
||||
if os.name != 'nt':
|
||||
fd = os.open(path, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except OSError as e:
|
||||
# Some filesystems don't like fsyncing directories and raise EINVAL. Ignoring it is usually safe.
|
||||
if e.errno != errno.EINVAL:
|
||||
raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class SlotsHandler(object):
|
||||
@@ -16,22 +36,74 @@ class SlotsHandler(object):
|
||||
def __init__(self, postgresql):
|
||||
self._postgresql = postgresql
|
||||
self._replication_slots = {} # already existing replication slots
|
||||
self._unready_logical_slots = set()
|
||||
self.schedule()
|
||||
|
||||
def _query(self, sql, *params):
|
||||
return self._postgresql.query(sql, *params, retry=False)
|
||||
|
||||
@staticmethod
|
||||
def _copy_items(src, dst, keys=None):
|
||||
dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')})
|
||||
|
||||
def process_permanent_slots(self, slots):
|
||||
"""This methods solves three problems at once (I know, it is weird).
|
||||
|
||||
The cluster_info_query from `Postgresql` is executed every HA loop and returns
|
||||
information about all replication slots that exists on the current host.
|
||||
Based on this information we perform the following actions:
|
||||
1. For the primary we want to expose to DCS permanent logical slots, therefore the method
|
||||
builds (and returns) a dict, that maps permanent logical slot names and confirmed_flush_lsns.
|
||||
2. This method also detects if one of the previously known permanent slots got missing and schedules resync.
|
||||
3. Updates the local cache with the fresh catalog_xmin and confirmed_flush_lsn for every known slot.
|
||||
This info is used when performing the check of logical slot readiness on standbys.
|
||||
"""
|
||||
ret = {}
|
||||
|
||||
slots = {slot['slot_name']: slot for slot in slots or []}
|
||||
if slots:
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots:
|
||||
if compare_slots(value, self._replication_slots[name], 'datoid'):
|
||||
if value['type'] == 'logical':
|
||||
ret[name] = value['confirmed_flush_lsn']
|
||||
self._copy_items(value, self._replication_slots[name])
|
||||
else:
|
||||
self._schedule_load_slots = True
|
||||
|
||||
# It could happen that the slots was deleted in the background, we want to detect this case
|
||||
if any(name not in slots for name in self._replication_slots.keys()):
|
||||
self._schedule_load_slots = True
|
||||
|
||||
return ret
|
||||
|
||||
def load_replication_slots(self):
|
||||
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
|
||||
replication_slots = {}
|
||||
cursor = self._query('SELECT slot_name, slot_type, plugin, database FROM pg_catalog.pg_replication_slots')
|
||||
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
|
||||
if self._postgresql.major_version >= 100000 else ""
|
||||
cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid'
|
||||
'{0} FROM pg_catalog.pg_replication_slots'.format(extra))
|
||||
for r in cursor:
|
||||
value = {'type': r[1]}
|
||||
if r[1] == 'logical':
|
||||
value.update({'plugin': r[2], 'database': r[3]})
|
||||
value.update(plugin=r[2], database=r[3], datoid=r[4])
|
||||
if self._postgresql.major_version >= 100000:
|
||||
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
|
||||
replication_slots[r[0]] = value
|
||||
self._replication_slots = replication_slots
|
||||
self._schedule_load_slots = False
|
||||
if self._force_readiness_check:
|
||||
self._unready_logical_slots = set(n for n, v in replication_slots.items() if v['type'] == 'logical')
|
||||
self._force_readiness_check = False
|
||||
|
||||
def ignore_replication_slot(self, cluster, name):
|
||||
slot = self._replication_slots[name]
|
||||
for matcher in cluster.config.ignore_slots_matchers:
|
||||
if ((matcher.get("name") is None or matcher["name"] == name)
|
||||
and all(not matcher.get(a) or matcher[a] == slot.get(a) for a in ('database', 'plugin', 'type'))):
|
||||
return True
|
||||
return False
|
||||
|
||||
def drop_replication_slot(self, name):
|
||||
cursor = self._query(('SELECT pg_catalog.pg_drop_replication_slot(%s) WHERE EXISTS (SELECT 1 ' +
|
||||
@@ -39,65 +111,192 @@ class SlotsHandler(object):
|
||||
# In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active
|
||||
return cursor.rowcount == 1
|
||||
|
||||
def sync_replication_slots(self, cluster):
|
||||
if self._postgresql.major_version >= 90400:
|
||||
def _drop_incorrect_slots(self, cluster, slots):
|
||||
# drop old replication slots which are not presented in desired slots
|
||||
for name in set(self._replication_slots) - set(slots):
|
||||
if not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
|
||||
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
|
||||
name, self._replication_slots[name], value)
|
||||
if self.drop_replication_slot(name):
|
||||
self._replication_slots.pop(name)
|
||||
else:
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def _ensure_physical_slots(self, slots):
|
||||
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
|
||||
for name, value in slots.items():
|
||||
if name not in self._replication_slots and value['type'] == 'physical':
|
||||
try:
|
||||
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
|
||||
immediately_reserve), name, name)
|
||||
except Exception:
|
||||
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
@contextmanager
|
||||
def _get_local_connection_cursor(self, database):
|
||||
conn_kwargs = self._postgresql.config.local_connect_kwargs
|
||||
conn_kwargs['database'] = database
|
||||
with get_connection_cursor(**conn_kwargs) as cur:
|
||||
yield cur
|
||||
|
||||
def _ensure_logical_slots_primary(self, slots):
|
||||
# Group logical slots to be created by database name
|
||||
logical_slots = defaultdict(dict)
|
||||
for name, value in slots.items():
|
||||
if value['type'] == 'logical':
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if self._replication_slots.get(name, {}).get('datoid'):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
else:
|
||||
logical_slots[value['database']][name] = value
|
||||
|
||||
# Create new logical slots
|
||||
for database, values in logical_slots.items():
|
||||
with self._get_local_connection_cursor(database) as cur:
|
||||
for name, value in values.items():
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'logical' AND slot_name = %s)",
|
||||
(name, value['plugin'], name))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create logical replication slot '%s' plugin='%s': %r",
|
||||
name, value['plugin'], e)
|
||||
slots.pop(name)
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def _ensure_logical_slots_replica(self, cluster, slots):
|
||||
advance_slots = defaultdict(dict) # Group logical slots to be advanced by database name
|
||||
create_slots = [] # And collect logical slots to be created on the replica
|
||||
for name, value in slots.items():
|
||||
if value['type'] == 'logical':
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if self._replication_slots.get(name, {}).get('datoid'):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
if name in cluster.slots:
|
||||
try: # Skip slots that doesn't need to be advanced
|
||||
if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
|
||||
advance_slots[value['database']][name] = value
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse "%s": %r', cluster.slots[name], e)
|
||||
elif name in cluster.slots: # We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
|
||||
# Advance logical slots
|
||||
for database, values in advance_slots.items():
|
||||
with self._get_local_connection_cursor(database) as cur:
|
||||
for name, value in values.items():
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)",
|
||||
(name, format_lsn(int(cluster.slots[name]))))
|
||||
except Exception as e:
|
||||
logger.error("Failed to advance logical replication slot '%s': %r", name, e)
|
||||
if isinstance(e, UndefinedFile):
|
||||
create_slots.append(name)
|
||||
self._schedule_load_slots = True
|
||||
return create_slots
|
||||
|
||||
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None):
|
||||
ret = None
|
||||
if self._postgresql.major_version >= 90400 and cluster.config:
|
||||
try:
|
||||
self.load_replication_slots()
|
||||
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role)
|
||||
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role,
|
||||
nofailover, self._postgresql.major_version, True)
|
||||
|
||||
# drop old replication slots which are not presented in desired slots
|
||||
for name in set(self._replication_slots) - set(slots):
|
||||
if not self.drop_replication_slot(name):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
self._drop_incorrect_slots(cluster, slots)
|
||||
|
||||
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
|
||||
self._ensure_physical_slots(slots)
|
||||
|
||||
logical_slots = defaultdict(dict)
|
||||
for name, value in slots.items():
|
||||
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
|
||||
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
|
||||
name, self._replication_slots[name], value)
|
||||
if not self.drop_replication_slot(name):
|
||||
logger.error("Failed to drop replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
continue
|
||||
self._replication_slots.pop(name)
|
||||
if name not in self._replication_slots:
|
||||
if value['type'] == 'physical':
|
||||
try:
|
||||
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
|
||||
immediately_reserve), name, name)
|
||||
except Exception:
|
||||
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
elif value['type'] == 'logical' and name not in self._replication_slots:
|
||||
logical_slots[value['database']][name] = value
|
||||
if self._postgresql.is_leader():
|
||||
self._unready_logical_slots.clear()
|
||||
self._ensure_logical_slots_primary(slots)
|
||||
elif cluster.slots and slots:
|
||||
self.check_logical_slots_readiness(cluster, nofailover, replicatefrom)
|
||||
|
||||
ret = self._ensure_logical_slots_replica(cluster, slots)
|
||||
|
||||
# create new logical slots
|
||||
for database, values in logical_slots.items():
|
||||
conn_kwargs = self._postgresql.config.local_connect_kwargs
|
||||
conn_kwargs['database'] = database
|
||||
with get_connection_cursor(**conn_kwargs) as cur:
|
||||
for name, value in values.items():
|
||||
try:
|
||||
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||
" WHERE slot_type = 'logical' AND slot_name = %s)",
|
||||
(name, value['plugin'], name))
|
||||
except Exception:
|
||||
logger.exception("Failed to create logical replication slot '%s' plugin='%s'",
|
||||
name, value['plugin'])
|
||||
self._schedule_load_slots = True
|
||||
self._replication_slots = slots
|
||||
except Exception:
|
||||
logger.exception('Exception when changing replication slots')
|
||||
self._schedule_load_slots = True
|
||||
return ret
|
||||
|
||||
@contextmanager
|
||||
def _get_leader_connection_cursor(self, leader):
|
||||
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
|
||||
conn_kwargs['database'] = self._postgresql.database
|
||||
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
|
||||
yield cur
|
||||
|
||||
def check_logical_slots_readiness(self, cluster, nofailover, replicatefrom):
|
||||
if self._unready_logical_slots:
|
||||
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
|
||||
try:
|
||||
with self._get_leader_connection_cursor(cluster.leader) as cur:
|
||||
cur.execute("SELECT catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
|
||||
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = %s", (slot_name,))
|
||||
if cur.rowcount < 1:
|
||||
return logger.warning('Physical slot %s does not exist on the primary', slot_name)
|
||||
catalog_xmin = cur.fetchone()[0]
|
||||
except Exception as e:
|
||||
return logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
|
||||
for name in list(self._unready_logical_slots):
|
||||
value = self._replication_slots.get(name)
|
||||
if not value or catalog_xmin <= value['catalog_xmin']:
|
||||
self._unready_logical_slots.remove(name)
|
||||
if value:
|
||||
logger.info('Logical slot %s is safe to be used after a failover', name)
|
||||
|
||||
def copy_logical_slots(self, leader, slots):
|
||||
with self._get_leader_connection_cursor(leader) as cur:
|
||||
try:
|
||||
cur.execute("SELECT slot_name, catalog_xmin, "
|
||||
"pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint, "
|
||||
"pg_catalog.pg_read_binary_file('pg_replslot/' || slot_name || '/state')"
|
||||
" FROM pg_catalog.pg_get_replication_slots() WHERE NOT pg_catalog.pg_is_in_recovery()"
|
||||
" AND slot_name = ANY(%s)", (slots,))
|
||||
slots = {r[0]: {'catalog_xmin': r[1], 'confirmed_flush_lsn': r[2], 'data': r[3]} for r in cur}
|
||||
except Exception as e:
|
||||
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
|
||||
|
||||
if isinstance(slots, dict) and self._postgresql.stop():
|
||||
pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
|
||||
for name, value in slots.items():
|
||||
slot_dir = os.path.join(pg_replslot_dir, name)
|
||||
slot_tmp_dir = slot_dir + '.tmp'
|
||||
if os.path.exists(slot_tmp_dir):
|
||||
shutil.rmtree(slot_tmp_dir)
|
||||
os.makedirs(slot_tmp_dir)
|
||||
fsync_dir(slot_tmp_dir)
|
||||
with open(os.path.join(slot_tmp_dir, 'state'), 'wb') as f:
|
||||
f.write(value['data'])
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if os.path.exists(slot_dir):
|
||||
shutil.rmtree(slot_dir)
|
||||
os.rename(slot_tmp_dir, slot_dir)
|
||||
fsync_dir(slot_dir)
|
||||
self._unready_logical_slots.add(name)
|
||||
fsync_dir(pg_replslot_dir)
|
||||
self._postgresql.start()
|
||||
|
||||
def schedule(self, value=None):
|
||||
if value is None:
|
||||
value = self._postgresql.major_version >= 90400
|
||||
self._schedule_load_slots = value
|
||||
self._schedule_load_slots = self._force_readiness_check = value
|
||||
|
||||
def on_promote(self):
|
||||
if self._unready_logical_slots:
|
||||
logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
|
||||
self._unready_logical_slots)
|
||||
|
||||
@@ -108,7 +108,7 @@ parameters = CaseInsensitiveDict({
|
||||
),
|
||||
'archive_timeout': Integer(90300, None, 0, 1073741823, 's'),
|
||||
'array_nulls': Bool(90300, None),
|
||||
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
|
||||
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
|
||||
'autovacuum': Bool(90300, None),
|
||||
'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None),
|
||||
'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None),
|
||||
@@ -151,12 +151,14 @@ parameters = CaseInsensitiveDict({
|
||||
Integer(90600, None, 30, 86400, 's')
|
||||
),
|
||||
'checkpoint_warning': Integer(90300, None, 0, 2147483647, 's'),
|
||||
'client_connection_check_interval': Integer(140000, None, '0', '2147483647', 'ms'),
|
||||
'client_encoding': String(90300, None),
|
||||
'client_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
|
||||
'debug1', 'log', 'notice', 'warning', 'error')),
|
||||
'cluster_name': String(90500, None),
|
||||
'commit_delay': Integer(90300, None, 0, 100000, None),
|
||||
'commit_siblings': Integer(90300, None, 0, 1000, None),
|
||||
'compute_query_id': EnumBool(140000, None, ('auto',)),
|
||||
'config_file': String(90300, None),
|
||||
'constraint_exclusion': EnumBool(90300, None, ('partition',)),
|
||||
'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
@@ -176,6 +178,7 @@ parameters = CaseInsensitiveDict({
|
||||
'default_table_access_method': String(120000, None),
|
||||
'default_tablespace': String(90300, None),
|
||||
'default_text_search_config': String(90300, None),
|
||||
'default_toast_compression': Enum(140000, None, ('pglz', 'lz4')),
|
||||
'default_transaction_deferrable': Bool(90300, None),
|
||||
'default_transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
|
||||
'read committed', 'read uncommitted')),
|
||||
@@ -188,6 +191,7 @@ parameters = CaseInsensitiveDict({
|
||||
),
|
||||
'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'),
|
||||
'effective_io_concurrency': Integer(90300, None, 0, 1000, None),
|
||||
'enable_async_append': Bool(140000, None),
|
||||
'enable_bitmapscan': Bool(90300, None),
|
||||
'enable_gathermerge': Bool(100000, None),
|
||||
'enable_hashagg': Bool(90300, None),
|
||||
@@ -209,6 +213,7 @@ parameters = CaseInsensitiveDict({
|
||||
'escape_string_warning': Bool(90300, None),
|
||||
'event_source': String(90300, None),
|
||||
'exit_on_error': Bool(90300, None),
|
||||
'extension_destdir': String(140000, None),
|
||||
'external_pid_file': String(90300, None),
|
||||
'extra_float_digits': Integer(90300, None, -15, 3, None),
|
||||
'force_parallel_mode': EnumBool(90600, None, ('regress',)),
|
||||
@@ -229,8 +234,10 @@ parameters = CaseInsensitiveDict({
|
||||
'hot_standby': Bool(90300, None),
|
||||
'hot_standby_feedback': Bool(90300, None),
|
||||
'huge_pages': EnumBool(90400, None, ('try',)),
|
||||
'huge_page_size': Integer(140000, None, '0', '2147483647', 'kB'),
|
||||
'ident_file': String(90300, None),
|
||||
'idle_in_transaction_session_timeout': Integer(90600, None, 0, 2147483647, 'ms'),
|
||||
'idle_session_timeout': Integer(140000, None, '0', '2147483647', 'ms'),
|
||||
'ignore_checksum_failure': Bool(90300, None),
|
||||
'ignore_invalid_pages': Bool(130000, None),
|
||||
'ignore_system_indexes': Bool(90300, None),
|
||||
@@ -283,6 +290,7 @@ parameters = CaseInsensitiveDict({
|
||||
'log_parameter_max_length_on_error': Integer(130000, None, -1, 1073741823, 'B'),
|
||||
'log_parser_stats': Bool(90300, None),
|
||||
'log_planner_stats': Bool(90300, None),
|
||||
'log_recovery_conflict_waits': Bool(140000, None),
|
||||
'log_replication_commands': Bool(90500, None),
|
||||
'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'),
|
||||
'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'),
|
||||
@@ -336,6 +344,7 @@ parameters = CaseInsensitiveDict({
|
||||
Integer(90400, 90600, 1, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'min_dynamic_shared_memory': Integer(140000, None, '0', '2147483647', 'MB'),
|
||||
'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
|
||||
'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
|
||||
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
|
||||
@@ -344,7 +353,7 @@ parameters = CaseInsensitiveDict({
|
||||
Integer(100000, None, 2, 2147483647, 'MB')
|
||||
),
|
||||
'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'),
|
||||
'operator_precedence_warning': Bool(90500, None),
|
||||
'operator_precedence_warning': Bool(90500, 140000),
|
||||
'parallel_leader_participation': Bool(110000, None),
|
||||
'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None),
|
||||
'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None),
|
||||
@@ -358,6 +367,8 @@ parameters = CaseInsensitiveDict({
|
||||
'pre_auth_delay': Integer(90300, None, 0, 60, 's'),
|
||||
'quote_all_identifiers': Bool(90300, None),
|
||||
'random_page_cost': Real(90300, None, 0, 1.79769e+308, None),
|
||||
'recovery_init_sync_method': Enum(140000, None, ('fsync', 'syncfs')),
|
||||
'remove_temp_files_after_crash': Bool(140000, None),
|
||||
'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None),
|
||||
'restart_after_crash': Bool(90300, None),
|
||||
'row_security': Bool(90500, None),
|
||||
@@ -373,6 +384,7 @@ parameters = CaseInsensitiveDict({
|
||||
'ssl_ca_file': String(90300, None),
|
||||
'ssl_cert_file': String(90300, None),
|
||||
'ssl_ciphers': String(90300, None),
|
||||
'ssl_crl_dir': String(140000, None),
|
||||
'ssl_crl_file': String(90300, None),
|
||||
'ssl_dh_params_file': String(100000, None),
|
||||
'ssl_ecdh_curve': String(90400, None),
|
||||
@@ -388,7 +400,7 @@ parameters = CaseInsensitiveDict({
|
||||
'stats_temp_directory': String(90300, None),
|
||||
'superuser_reserved_connections': (
|
||||
Integer(90300, 90600, 0, 8388607, None),
|
||||
Integer(90600, None, 0, 262143, None),
|
||||
Integer(90600, None, 0, 262143, None)
|
||||
),
|
||||
'synchronize_seqscans': Bool(90300, None),
|
||||
'synchronous_commit': (
|
||||
@@ -424,6 +436,7 @@ parameters = CaseInsensitiveDict({
|
||||
'track_counts': Bool(90300, None),
|
||||
'track_functions': Enum(90300, None, ('none', 'pl', 'all')),
|
||||
'track_io_timing': Bool(90300, None),
|
||||
'track_wal_io_timing': Bool(140000, None),
|
||||
'transaction_deferrable': Bool(90300, None),
|
||||
'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
|
||||
'read committed', 'read uncommitted')),
|
||||
@@ -433,7 +446,7 @@ parameters = CaseInsensitiveDict({
|
||||
'unix_socket_group': String(90300, None),
|
||||
'unix_socket_permissions': Integer(90300, None, 0, 511, None),
|
||||
'update_process_title': Bool(90300, None),
|
||||
'vacuum_cleanup_index_scale_factor': Real(110000, None, 0, 1e+10, None),
|
||||
'vacuum_cleanup_index_scale_factor': Real(110000, 140000, 0, 1e+10, None),
|
||||
'vacuum_cost_delay': (
|
||||
Integer(90300, 120000, 0, 100, 'ms'),
|
||||
Real(120000, None, 0, 100, 'ms')
|
||||
@@ -443,8 +456,10 @@ parameters = CaseInsensitiveDict({
|
||||
'vacuum_cost_page_hit': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_cost_page_miss': Integer(90300, None, 0, 10000, None),
|
||||
'vacuum_defer_cleanup_age': Integer(90300, None, 0, 1000000, None),
|
||||
'vacuum_failsafe_age': Integer(140000, None, '0', '2100000000', None),
|
||||
'vacuum_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
|
||||
'vacuum_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
|
||||
'vacuum_multixact_failsafe_age': Integer(140000, None, '0', '2100000000', None),
|
||||
'vacuum_multixact_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
|
||||
'vacuum_multixact_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
|
||||
'wal_buffers': Integer(90300, None, -1, 262143, '8kB'),
|
||||
@@ -484,7 +499,7 @@ recovery_parameters = CaseInsensitiveDict({
|
||||
'promote_trigger_file': String(120000, None),
|
||||
'recovery_end_command': String(90300, None),
|
||||
'recovery_min_apply_delay': Integer(90400, None, 0, 2147483647, 'ms'),
|
||||
'recovery_target': Enum(90400, None, ('immediate',)),
|
||||
'recovery_target': Enum(90400, None, ('immediate', '')),
|
||||
'recovery_target_action': Enum(90500, None, ('pause', 'promote', 'shutdown')),
|
||||
'recovery_target_inclusive': Bool(90300, None),
|
||||
'recovery_target_lsn': String(100000, None),
|
||||
@@ -511,6 +526,8 @@ def _transform_parameter_value(validators, version, name, value):
|
||||
def transform_postgresql_parameter_value(version, name, value):
|
||||
if '.' in name:
|
||||
return value
|
||||
if name in recovery_parameters:
|
||||
return None
|
||||
return _transform_parameter_value(parameters, version, name, value)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from patroni.daemon import AbstractPatroniDaemon, abstract_main
|
||||
from patroni.dcs.raft import KVStoreTTL
|
||||
from pysyncobj import SyncObjConf
|
||||
from .daemon import AbstractPatroniDaemon, abstract_main
|
||||
from .dcs.raft import KVStoreTTL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,16 +11,13 @@ class RaftController(AbstractPatroniDaemon):
|
||||
def __init__(self, config):
|
||||
super(RaftController, self).__init__(config)
|
||||
|
||||
raft_config = self.config.get('raft')
|
||||
self_addr = raft_config['self_addr']
|
||||
template = os.path.join(raft_config.get('data_dir', ''), self_addr)
|
||||
self._syncobj_config = SyncObjConf(autoTick=False, appendEntriesUseBatch=False, dynamicMembershipChange=True,
|
||||
journalFile=template + '.journal', fullDumpFile=template + '.dump')
|
||||
self._raft = KVStoreTTL(self_addr, raft_config.get('partner_addrs', []), self._syncobj_config)
|
||||
config = self.config.get('raft')
|
||||
assert 'self_addr' in config
|
||||
self._raft = KVStoreTTL(None, None, None, **config)
|
||||
|
||||
def _run_cycle(self):
|
||||
try:
|
||||
self._raft.doTick(self._syncobj_config.autoTickPeriod)
|
||||
self._raft.doTick(self._raft.conf.autoTickPeriod)
|
||||
except Exception:
|
||||
logger.exception('doTick')
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ class WALERestore(object):
|
||||
diff_in_bytes = 0
|
||||
break
|
||||
|
||||
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
|
||||
# if the size of the accumulated WAL segments is more than a certain percentage of the backup size
|
||||
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
|
||||
is_size_thresh_ok = diff_in_bytes < int(threshold_megabytes) * 1048576
|
||||
threshold_pct_bytes = backup_size * threshold_percent / 100.0
|
||||
@@ -308,7 +308,7 @@ class WALERestore(object):
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except OSError:
|
||||
logger.exception("coud not create missing %s directory path", dirname)
|
||||
logger.exception("could not create missing %s directory path", dirname)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
+26
-6
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
import json.decoder as json_decoder
|
||||
import logging
|
||||
import os
|
||||
@@ -404,7 +405,7 @@ def is_standby_cluster(config):
|
||||
|
||||
def cluster_as_json(cluster):
|
||||
leader_name = cluster.leader.name if cluster.leader else None
|
||||
xlog_location_cluster = cluster.last_leader_operation or 0
|
||||
cluster_lsn = cluster.last_lsn or 0
|
||||
|
||||
ret = {'members': []}
|
||||
for m in cluster.members:
|
||||
@@ -426,11 +427,11 @@ def cluster_as_json(cluster):
|
||||
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
|
||||
|
||||
if m.name != leader_name:
|
||||
xlog_location = m.data.get('xlog_location')
|
||||
if xlog_location is None:
|
||||
lsn = m.data.get('xlog_location')
|
||||
if lsn is None:
|
||||
member['lag'] = 'unknown'
|
||||
elif xlog_location_cluster >= xlog_location:
|
||||
member['lag'] = xlog_location_cluster - xlog_location
|
||||
elif cluster_lsn >= lsn:
|
||||
member['lag'] = cluster_lsn - lsn
|
||||
else:
|
||||
member['lag'] = 0
|
||||
|
||||
@@ -461,7 +462,8 @@ def validate_directory(d, msg="{} {}"):
|
||||
os.makedirs(d)
|
||||
except OSError as e:
|
||||
logger.error(e)
|
||||
raise PatroniException(msg.format(d, "couldn't create the directory"))
|
||||
if e.errno != errno.EEXIST:
|
||||
raise PatroniException(msg.format(d, "couldn't create the directory"))
|
||||
elif os.path.isdir(d):
|
||||
try:
|
||||
fd, tmpfile = tempfile.mkstemp(dir=d)
|
||||
@@ -512,3 +514,21 @@ def enable_keepalive(sock, timeout, idle, cnt=3):
|
||||
|
||||
for opt in keepalive_socket_options(timeout, idle, cnt):
|
||||
sock.setsockopt(*opt)
|
||||
|
||||
|
||||
def find_executable(executable, path=None):
|
||||
_, ext = os.path.splitext(executable)
|
||||
|
||||
if (sys.platform == 'win32') and (ext != '.exe'):
|
||||
executable = executable + '.exe'
|
||||
|
||||
if os.path.isfile(executable):
|
||||
return executable
|
||||
|
||||
if path is None:
|
||||
path = os.environ.get('PATH', os.defpath)
|
||||
|
||||
for p in path.split(os.pathsep):
|
||||
f = os.path.join(p, executable)
|
||||
if os.path.isfile(f):
|
||||
return f
|
||||
|
||||
+29
-14
@@ -4,12 +4,12 @@ import socket
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from patroni.utils import split_host_port, data_directory_is_empty
|
||||
from patroni.ctl import find_executable
|
||||
from patroni.dcs import dcs_modules
|
||||
from patroni.exceptions import ConfigParseError
|
||||
from six import string_types
|
||||
|
||||
from .utils import find_executable, split_host_port, data_directory_is_empty
|
||||
from .dcs import dcs_modules
|
||||
from .exceptions import ConfigParseError
|
||||
|
||||
|
||||
def data_directory_empty(data_dir):
|
||||
if os.path.isfile(os.path.join(data_dir, "global", "pg_control")):
|
||||
@@ -53,11 +53,15 @@ def validate_host_port(host_port, listen=False, multiple_hosts=False):
|
||||
return True
|
||||
|
||||
|
||||
def comma_separated_host_port(string):
|
||||
assert all([validate_host_port(s.strip()) for s in string.split(",")]), "didn't pass the validation"
|
||||
def validate_host_port_list(value):
|
||||
assert all([validate_host_port(v) for v in value]), "didn't pass the validation"
|
||||
return True
|
||||
|
||||
|
||||
def comma_separated_host_port(string):
|
||||
return validate_host_port_list([s.strip() for s in string.split(",")])
|
||||
|
||||
|
||||
def validate_host_port_listen(host_port):
|
||||
return validate_host_port(host_port, listen=True)
|
||||
|
||||
@@ -291,11 +295,21 @@ def assert_(condition, message="Wrong value"):
|
||||
|
||||
userattributes = {"username": "", Optional("password"): ""}
|
||||
available_dcs = [m.split(".")[-1] for m in dcs_modules()]
|
||||
validate_host_port_list.expected_type = list
|
||||
comma_separated_host_port.expected_type = string_types
|
||||
validate_connect_address.expected_type = string_types
|
||||
validate_host_port_listen.expected_type = string_types
|
||||
validate_host_port_listen_multiple_hosts.expected_type = string_types
|
||||
validate_data_dir.expected_type = string_types
|
||||
validate_etcd = {
|
||||
Or("host", "hosts", "srv", "srv_suffix", "url", "proxy"): Case({
|
||||
"host": validate_host_port,
|
||||
"hosts": Or(comma_separated_host_port, [validate_host_port]),
|
||||
"srv": str,
|
||||
"srv_suffix": str,
|
||||
"url": str,
|
||||
"proxy": str})
|
||||
}
|
||||
|
||||
schema = Schema({
|
||||
"name": str,
|
||||
@@ -320,19 +334,20 @@ schema = Schema({
|
||||
"host": validate_host_port,
|
||||
"url": str})
|
||||
},
|
||||
"etcd": {
|
||||
Or("host", "hosts", "srv", "url", "proxy"): Case({
|
||||
"host": validate_host_port,
|
||||
"hosts": Or(comma_separated_host_port, [validate_host_port]),
|
||||
"srv": str,
|
||||
"url": str,
|
||||
"proxy": str})
|
||||
},
|
||||
"etcd": validate_etcd,
|
||||
"etcd3": validate_etcd,
|
||||
"exhibitor": {
|
||||
"hosts": [str],
|
||||
"port": lambda i: assert_(int(i) <= 65535),
|
||||
Optional("pool_interval"): int
|
||||
},
|
||||
"raft": {
|
||||
"self_addr": validate_connect_address,
|
||||
Optional("bind_addr"): validate_host_port_listen,
|
||||
"partner_addrs": validate_host_port_list,
|
||||
Optional("data_dir"): str,
|
||||
Optional("password"): str
|
||||
},
|
||||
"zookeeper": {
|
||||
"hosts": Or(comma_separated_host_port, [validate_host_port]),
|
||||
},
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = '2.0.0'
|
||||
__version__ = '2.1.1'
|
||||
|
||||
@@ -57,10 +57,15 @@ bootstrap:
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# max_connections: 100
|
||||
# max_worker_processes: 8
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 10
|
||||
# max_replication_slots: 10
|
||||
# max_prepared_transactions: 0
|
||||
# max_locks_per_transaction: 64
|
||||
# wal_log_hints: "on"
|
||||
# track_commit_timestamp: "off"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
|
||||
@@ -51,10 +51,15 @@ bootstrap:
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# max_connections: 100
|
||||
# max_worker_processes: 8
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 10
|
||||
# max_replication_slots: 10
|
||||
# max_prepared_transactions: 0
|
||||
# max_locks_per_transaction: 64
|
||||
# wal_log_hints: "on"
|
||||
# track_commit_timestamp: "off"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
@@ -112,6 +117,7 @@ postgresql:
|
||||
basebackup:
|
||||
- verbose
|
||||
- max-rate: 100M
|
||||
# - waldir: /pg-wal-mount/external-waldir # only needed in case pg_wal is symlinked outside of data_dir
|
||||
# Additional fencing script executed after acquiring the leader lock but before promoting the replica
|
||||
#pre_promote: /path/to/pre_promote.sh
|
||||
|
||||
|
||||
@@ -51,10 +51,15 @@ bootstrap:
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# max_connections: 100
|
||||
# max_worker_processes: 8
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 10
|
||||
# max_replication_slots: 10
|
||||
# max_prepared_transactions: 0
|
||||
# max_locks_per_transaction: 64
|
||||
# wal_log_hints: "on"
|
||||
# track_commit_timestamp: "off"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
psycopg2-binary
|
||||
behave
|
||||
coverage
|
||||
flake8
|
||||
mock
|
||||
pytest-cov
|
||||
pytest
|
||||
setuptools
|
||||
+5
-3
@@ -1,4 +1,5 @@
|
||||
urllib3[secure]>=1.19.1,!=1.21
|
||||
urllib3>=1.19.1,!=1.21
|
||||
ipaddress; python_version=="2.7"
|
||||
boto
|
||||
PyYAML
|
||||
six >= 1.7
|
||||
@@ -8,6 +9,7 @@ python-consul>=0.7.1
|
||||
click>=4.1
|
||||
prettytable>=0.7
|
||||
python-dateutil
|
||||
pysyncobj>=0.3.5
|
||||
pysyncobj>=0.3.8
|
||||
cryptography>=1.4
|
||||
psutil>=2.0.0
|
||||
cdiff
|
||||
ydiff>=1.2.0
|
||||
|
||||
@@ -22,8 +22,9 @@ AUTHOR_EMAIL = '[email protected], [email protected], alexk
|
||||
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
|
||||
' zookeeper exhibitor consul streaming replication kubernetes k8s'
|
||||
|
||||
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'], 'consul': ['python-consul'],
|
||||
'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'kubernetes': [], 'raft': ['pysyncobj']}
|
||||
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
|
||||
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
|
||||
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
|
||||
COVERAGE_XML = True
|
||||
COVERAGE_HTML = False
|
||||
|
||||
@@ -46,6 +47,8 @@ CLASSIFIERS = [
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: Implementation :: CPython',
|
||||
]
|
||||
|
||||
@@ -143,9 +146,13 @@ class PyTest(Command):
|
||||
|
||||
def run(self):
|
||||
from pkg_resources import evaluate_marker
|
||||
requirements = self.distribution.install_requires + ['mock>=2.0.0', 'pytest-cov', 'pytest'] +\
|
||||
[v for k, v in self.distribution.extras_require.items() if not k.startswith(':') or evaluate_marker(k[1:])]
|
||||
self.distribution.fetch_build_eggs(requirements)
|
||||
|
||||
requirements = set(self.distribution.install_requires + ['mock>=2.0.0', 'pytest-cov', 'pytest'])
|
||||
for k, v in self.distribution.extras_require.items():
|
||||
if not k.startswith(':') or evaluate_marker(k[1:]):
|
||||
requirements.update(v)
|
||||
|
||||
self.distribution.fetch_build_eggs(list(requirements))
|
||||
self.run_tests()
|
||||
|
||||
|
||||
@@ -165,10 +172,15 @@ def setup_package(version):
|
||||
if r == '':
|
||||
continue
|
||||
extra = False
|
||||
for e, v in EXTRAS_REQUIRE.items():
|
||||
if v and r.startswith(v[0]):
|
||||
EXTRAS_REQUIRE[e] = [r]
|
||||
extra = True
|
||||
for e, deps in EXTRAS_REQUIRE.items():
|
||||
for i, v in enumerate(deps):
|
||||
if r.startswith(v):
|
||||
deps[i] = r
|
||||
EXTRAS_REQUIRE[e] = deps
|
||||
extra = True
|
||||
break
|
||||
if extra:
|
||||
break
|
||||
if not extra:
|
||||
install_requires.append(r)
|
||||
|
||||
|
||||
+11
-5
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
@@ -10,7 +11,7 @@ import urllib3
|
||||
from patroni.dcs import Leader, Member
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
from patroni.utils import RetryFailedError
|
||||
from patroni.utils import RetryFailedError, tzutc
|
||||
|
||||
|
||||
class SleepException(Exception):
|
||||
@@ -89,16 +90,21 @@ class MockCursor(object):
|
||||
raise psycopg2.OperationalError()
|
||||
elif sql.startswith('RetryFailedError'):
|
||||
raise RetryFailedError('retry')
|
||||
elif sql.startswith('SELECT catalog_xmin'):
|
||||
self.results = [(100, 501)]
|
||||
elif sql.startswith('SELECT slot_name, catalog_xmin'):
|
||||
self.results = [('ls', 100, 500, b'123456')]
|
||||
elif sql.startswith('SELECT slot_name'):
|
||||
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b')]
|
||||
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b', 5, 100, 500)]
|
||||
elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'):
|
||||
self.results = [(1, 2, 1, 0, False, 1, 1, None, None)]
|
||||
self.results = [(1, 2, 1, 0, False, 1, 1, None, None, [{"slot_name": "ls", "confirmed_flush_lsn": 12345}])]
|
||||
elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'):
|
||||
self.results = [(False, 2)]
|
||||
elif sql.startswith('SELECT pg_catalog.to_char'):
|
||||
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
|
||||
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
|
||||
'"state":"streaming","sync_state":"async","sync_priority":0}]'
|
||||
self.results = [('', 0, '', 0, '', '', False, replication_info)]
|
||||
now = datetime.datetime.now(tzutc)
|
||||
self.results = [(now, 0, '', 0, '', False, now, replication_info)]
|
||||
elif sql.startswith('SELECT name, setting'):
|
||||
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('wal_block_size', '8192', None, 'integer', 'internal'),
|
||||
|
||||
+123
-8
@@ -30,7 +30,7 @@ class MockPostgresql(object):
|
||||
pending_restart = True
|
||||
wal_name = 'wal'
|
||||
lsn_name = 'lsn'
|
||||
POSTMASTER_START_TIME = 'pg_catalog.to_char(pg_catalog.pg_postmaster_start_time'
|
||||
POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
|
||||
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
|
||||
|
||||
@staticmethod
|
||||
@@ -39,7 +39,7 @@ class MockPostgresql(object):
|
||||
|
||||
@staticmethod
|
||||
def postmaster_start_time():
|
||||
return str(postmaster_start_time)
|
||||
return postmaster_start_time
|
||||
|
||||
@staticmethod
|
||||
def replica_cached_timeline(_):
|
||||
@@ -118,7 +118,7 @@ class MockPatroni(object):
|
||||
postgresql = ha.state_handler
|
||||
dcs = Mock()
|
||||
logger = MockLogger()
|
||||
tags = {}
|
||||
tags = {"key1": True, "key2": False, "key3": 1, "key4": 1.4, "key5": "RandomTag"}
|
||||
version = '0.00'
|
||||
noloadbalance = PropertyMock(return_value=False)
|
||||
scheduled_restart = {'schedule': future_restart_time,
|
||||
@@ -162,7 +162,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||
|
||||
def test_do_GET(self):
|
||||
MockPatroni.dcs.cluster.last_leader_operation = 20
|
||||
MockPatroni.dcs.cluster.last_lsn = 20
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
|
||||
@@ -174,7 +174,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'state': 'running'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /health')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master')
|
||||
MockRestApiServer(RestApiHandler, 'GET /leader')
|
||||
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
|
||||
MockPatroni.dcs.cluster.is_synchronous_mode = Mock(return_value=True)
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
||||
@@ -197,6 +197,89 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||
|
||||
# test tags
|
||||
#
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'standby_leader'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
#
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=False&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
|
||||
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
|
||||
'tag_key1=true&tag_key2=false&'
|
||||
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
|
||||
|
||||
def test_do_OPTIONS(self):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
|
||||
|
||||
@@ -236,6 +319,10 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
mock_dcs.cluster.config = None
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_GET_metrics(self, mock_dcs):
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /metrics'))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_PATCH_config(self, mock_dcs):
|
||||
config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}}
|
||||
@@ -446,10 +533,14 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
class TestRestApiServer(unittest.TestCase):
|
||||
|
||||
@patch('ssl.SSLContext.load_cert_chain', Mock())
|
||||
@patch('ssl.SSLContext.set_ciphers', Mock())
|
||||
@patch('ssl.SSLContext.wrap_socket', Mock(return_value=0))
|
||||
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
|
||||
def setUp(self):
|
||||
self.srv = MockRestApiServer(Mock(), '', {'listen': '*:8008', 'certfile': 'a', 'verify_client': 'required'})
|
||||
self.srv = MockRestApiServer(Mock(), '', {'listen': '*:8008', 'certfile': 'a', 'verify_client': 'required',
|
||||
'ciphers': '!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1',
|
||||
'allowlist': ['127.0.0.1', '::1/128', '::1/zxc'],
|
||||
'allowlist_include_members': True})
|
||||
|
||||
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
|
||||
def test_reload_config(self):
|
||||
@@ -460,10 +551,17 @@ class TestRestApiServer(unittest.TestCase):
|
||||
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)):
|
||||
self.srv.reload_config({'listen': ':8008'})
|
||||
|
||||
def test_check_auth(self):
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_check_access(self, mock_dcs):
|
||||
mock_dcs.cluster = get_cluster_initialized_without_leader()
|
||||
mock_dcs.cluster.members[1].data['api_url'] = 'http://127.0.0.1z:8011/patroni'
|
||||
mock_dcs.cluster.members.append(Member(0, 'bad-api-url', 30, {'api_url': 123}))
|
||||
mock_rh = Mock()
|
||||
mock_rh.client_address = ('127.0.0.2',)
|
||||
self.assertIsNot(self.srv.check_access(mock_rh), True)
|
||||
mock_rh.client_address = ('127.0.0.1',)
|
||||
mock_rh.request.getpeercert.return_value = None
|
||||
self.assertIsNot(self.srv.check_auth(mock_rh), True)
|
||||
self.assertIsNot(self.srv.check_access(mock_rh), True)
|
||||
|
||||
def test_handle_error(self):
|
||||
try:
|
||||
@@ -488,3 +586,20 @@ class TestRestApiServer(unittest.TestCase):
|
||||
mock_accept.return_value = (newsock, '2')
|
||||
self.srv.socket = Mock()
|
||||
self.assertEqual(self.srv.get_request(), ((self.srv.socket, newsock), '2'))
|
||||
|
||||
@patch.object(MockRestApiServer, 'process_request', Mock(side_effect=RuntimeError))
|
||||
def test_process_request_error(self):
|
||||
mock_address = ('127.0.0.1', 55555)
|
||||
mock_socket = Mock()
|
||||
mock_ssl_socket = (Mock(), Mock())
|
||||
for mock_request in (mock_socket, mock_ssl_socket):
|
||||
with patch.object(
|
||||
MockRestApiServer,
|
||||
'get_request',
|
||||
Mock(return_value=(mock_request, mock_address))
|
||||
):
|
||||
self.srv._handle_request_noblock()
|
||||
|
||||
@patch('ssl._ssl._test_decode_cert', Mock())
|
||||
def test_reload_local_certificate(self):
|
||||
self.assertTrue(self.srv.reload_local_certificate())
|
||||
|
||||
@@ -14,6 +14,7 @@ class TestCallbackExecutor(unittest.TestCase):
|
||||
|
||||
ce = CallbackExecutor()
|
||||
ce._kill_children = Mock(side_effect=Exception)
|
||||
ce._invoke_excepthook = Mock()
|
||||
self.assertIsNone(ce.call([]))
|
||||
ce.join()
|
||||
|
||||
@@ -30,5 +31,6 @@ class TestCallbackExecutor(unittest.TestCase):
|
||||
mock_popen.side_effect = Exception
|
||||
ce = CallbackExecutor()
|
||||
ce._condition.wait = Mock(side_effect=[None, Exception])
|
||||
ce._invoke_excepthook = Mock()
|
||||
self.assertIsNone(ce.call([]))
|
||||
ce.join()
|
||||
|
||||
@@ -30,12 +30,14 @@ class TestConfig(unittest.TestCase):
|
||||
'PATRONI_SCOPE': 'batman2',
|
||||
'PATRONI_LOGLEVEL': 'ERROR',
|
||||
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
|
||||
'PATRONI_LOG_FILE_NUM': '5',
|
||||
'PATRONI_RESTAPI_USERNAME': 'username',
|
||||
'PATRONI_RESTAPI_PASSWORD': 'password',
|
||||
'PATRONI_RESTAPI_LISTEN': '0.0.0.0:8008',
|
||||
'PATRONI_RESTAPI_CONNECT_ADDRESS': '127.0.0.1:8008',
|
||||
'PATRONI_RESTAPI_CERTFILE': '/certfile',
|
||||
'PATRONI_RESTAPI_KEYFILE': '/keyfile',
|
||||
'PATRONI_RESTAPI_ALLOWLIST_INCLUDE_MEMBERS': 'on',
|
||||
'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432',
|
||||
'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432',
|
||||
'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0',
|
||||
|
||||
+70
-9
@@ -4,7 +4,7 @@ import unittest
|
||||
from consul import ConsulException, NotFound
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
|
||||
ConsulError, HTTPClient, InvalidSessionTTL, InvalidSession
|
||||
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession
|
||||
from . import SleepException
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ def kv_get(self, key, **kwargs):
|
||||
return None, None
|
||||
if key == 'service/good/leader':
|
||||
return '1', None
|
||||
if key == 'service/good/':
|
||||
return ('6429',
|
||||
good_cls = ('6429',
|
||||
[{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0,
|
||||
'ModifyIndex': 1334, 'Value': b''},
|
||||
{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0,
|
||||
@@ -34,14 +33,25 @@ def kv_get(self, key, **kwargs):
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'4496294792'},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'sync', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'}])
|
||||
'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'status', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'{"optime":4496294792, "slots":{"ls":12345}}'}])
|
||||
if key == 'service/good/':
|
||||
return good_cls
|
||||
if key == 'service/broken/':
|
||||
good_cls[1][-1]['Value'] = b'{'
|
||||
return good_cls
|
||||
if key == 'service/legacy/':
|
||||
good_cls[1].pop()
|
||||
return good_cls
|
||||
raise ConsulException
|
||||
|
||||
|
||||
class TestHTTPClient(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = HTTPClient('127.0.0.1', '8500', 'http', False)
|
||||
c = ConsulClient()
|
||||
self.client = c.http
|
||||
self.client.http.request = Mock()
|
||||
|
||||
def test_get(self):
|
||||
@@ -108,6 +118,10 @@ class TestConsul(unittest.TestCase):
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.c._base_path = '/service/fail'
|
||||
self.assertRaises(ConsulError, self.c.get_cluster)
|
||||
self.c._base_path = '/service/broken'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.c._base_path = '/service/legacy'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.c._base_path = '/service/good'
|
||||
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
@@ -116,8 +130,9 @@ class TestConsul(unittest.TestCase):
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException, InvalidSession]))
|
||||
def test_touch_member(self):
|
||||
self.c.refresh_session = Mock(return_value=False)
|
||||
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||
'api_url': 'http://127.0.0.1:8009/patroni'})
|
||||
with patch.object(Consul, 'update_service', Mock(side_effect=Exception)):
|
||||
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||
'api_url': 'http://127.0.0.1:8009/patroni'})
|
||||
self.c._register_service = True
|
||||
self.c.refresh_session = Mock(return_value=True)
|
||||
for _ in range(0, 4):
|
||||
@@ -145,7 +160,7 @@ class TestConsul(unittest.TestCase):
|
||||
|
||||
@patch.object(consul.Consul.Session, 'renew', Mock())
|
||||
def test_update_leader(self):
|
||||
self.c.update_leader(None)
|
||||
self.c.update_leader(12345)
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
|
||||
def test_delete_leader(self):
|
||||
@@ -201,4 +216,50 @@ class TestConsul(unittest.TestCase):
|
||||
self.assertIsNone(self.c.update_service({}, d))
|
||||
|
||||
def test_reload_config(self):
|
||||
self.c.reload_config({'consul': {'token': 'foo'}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
self.assertEqual([], self.c._service_tags)
|
||||
self.c.reload_config({'consul': {'token': 'foo', 'register_service': True, 'service_tags': ['foo']},
|
||||
'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
self.assertEqual(["foo"], self.c._service_tags)
|
||||
|
||||
self.c.refresh_session = Mock(return_value=False)
|
||||
|
||||
d = {'role': 'replica', 'api_url': 'http://a/t', 'conn_url': 'pg://c:1', 'state': 'running'}
|
||||
|
||||
# Changing register_service from True to False calls deregister()
|
||||
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
|
||||
self.c.touch_member(d)
|
||||
mock_deregister.assert_called_once()
|
||||
|
||||
self.assertEqual([], self.c._service_tags)
|
||||
|
||||
# register_service staying False between reloads does not call deregister()
|
||||
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
|
||||
self.c.touch_member(d)
|
||||
self.assertFalse(mock_deregister.called)
|
||||
|
||||
# Changing register_service from False to True calls register()
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
# register_service staying True between reloads does not call register()
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
self.assertFalse(mock_deregister.called)
|
||||
|
||||
# register_service staying True between reloads does calls register() if other service data has changed
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
# register_service staying True between reloads does calls register() if service_tags have changed
|
||||
self.c.reload_config({'consul': {'register_service': True, 'service_tags': ['foo']}, 'loop_wait': 10,
|
||||
'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
+5
-11
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
||||
from mock import patch, Mock
|
||||
from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs, parse_dcs, \
|
||||
get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \
|
||||
format_config_for_editing, show_diff, invoke_editor, format_pg_version, find_executable, CONFIG_FILE_PATH
|
||||
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH
|
||||
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Failover
|
||||
from patroni.utils import tzutc
|
||||
from psycopg2 import OperationalError
|
||||
@@ -561,7 +561,7 @@ class TestCtl(unittest.TestCase):
|
||||
self.assertRaises(PatroniCtlException, apply_config_changes, before_editing, config, ['a'])
|
||||
|
||||
@patch('sys.stdout.isatty', return_value=False)
|
||||
@patch('cdiff.markup_to_pager')
|
||||
@patch('patroni.ctl.markup_to_pager')
|
||||
def test_show_diff(self, mock_markup_to_pager, mock_isatty):
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
mock_markup_to_pager.assert_not_called()
|
||||
@@ -570,6 +570,9 @@ class TestCtl(unittest.TestCase):
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
mock_markup_to_pager.assert_called_once()
|
||||
|
||||
with patch('patroni.ctl.find_executable', Mock(return_value=None)):
|
||||
show_diff("foo:\n bar: 1\n", "foo:\n bar: 2\n")
|
||||
|
||||
# Test that unicode handling doesn't fail with an exception
|
||||
show_diff(b"foo:\n bar: \xc3\xb6\xc3\xb6\n".decode('utf-8'),
|
||||
b"foo:\n bar: \xc3\xbc\xc3\xbc\n".decode('utf-8'))
|
||||
@@ -626,15 +629,6 @@ class TestCtl(unittest.TestCase):
|
||||
self.assertEqual(format_pg_version(100001), '10.1')
|
||||
self.assertEqual(format_pg_version(90605), '9.6.5')
|
||||
|
||||
@patch('sys.platform', 'win32')
|
||||
def test_find_executable(self):
|
||||
with patch('os.path.isfile', Mock(return_value=True)):
|
||||
self.assertEqual(find_executable('vim'), 'vim.exe')
|
||||
with patch('os.path.isfile', Mock(return_value=False)):
|
||||
self.assertIsNone(find_executable('vim'))
|
||||
with patch('os.path.isfile', Mock(side_effect=[False, True])):
|
||||
self.assertEqual(find_executable('vim', '/'), '/vim.exe')
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_get_members(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
|
||||
+15
-2
@@ -66,7 +66,13 @@ def etcd_read(self, key, **kwargs):
|
||||
"?application_name=http://127.0.0.1:8008/patroni",
|
||||
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
|
||||
"modifiedIndex": 20730, "createdIndex": 20730}],
|
||||
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
||||
"modifiedIndex": 1581, "createdIndex": 1581},
|
||||
{"key": "/service/batman5/status", "value": '{"optime":2164261704,"slots":{"ls":12345}}',
|
||||
"modifiedIndex": 1582, "createdIndex": 1582}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
||||
if key == '/service/legacy/':
|
||||
response['node']['nodes'].pop()
|
||||
if key == '/service/broken/':
|
||||
response['node']['nodes'][-1]['value'] = '{'
|
||||
result = etcd.EtcdResult(**response)
|
||||
result.etcd_index = 0
|
||||
return result
|
||||
@@ -81,7 +87,8 @@ def dns_query(name, _):
|
||||
raise DNSException()
|
||||
srv = Mock()
|
||||
srv.port = 2380
|
||||
srv.target.to_text.return_value = 'localhost' if name == '_etcd-server._tcp.foobar' else '127.0.0.1'
|
||||
srv.target.to_text.return_value = \
|
||||
'localhost' if name in ['_etcd-server._tcp.foobar', '_etcd-server-baz._tcp.foobar'] else '127.0.0.1'
|
||||
return [srv]
|
||||
|
||||
|
||||
@@ -110,6 +117,7 @@ class TestDnsCachingResolver(unittest.TestCase):
|
||||
@patch('socket.getaddrinfo', Mock(side_effect=socket.gaierror))
|
||||
def test_run(self):
|
||||
r = DnsCachingResolver()
|
||||
r._invoke_excepthook = Mock()
|
||||
self.assertIsNone(r.resolve_async('', 0))
|
||||
r.join()
|
||||
|
||||
@@ -176,6 +184,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def test__get_machines_cache_from_srv(self):
|
||||
self.client._get_machines_cache_from_srv('foobar')
|
||||
self.client._get_machines_cache_from_srv('foobar', 'baz')
|
||||
self.client.get_srv_record = Mock(return_value=[('localhost', 2380)])
|
||||
self.client._get_machines_cache_from_srv('blabla')
|
||||
|
||||
@@ -245,6 +254,10 @@ class TestEtcd(unittest.TestCase):
|
||||
cluster = self.etcd.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertFalse(cluster.is_synchronous_mode())
|
||||
self.etcd._base_path = '/service/legacy'
|
||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
||||
self.etcd._base_path = '/service/broken'
|
||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
||||
self.etcd._base_path = '/service/nocluster'
|
||||
cluster = self.etcd.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
|
||||
+32
-3
@@ -4,8 +4,9 @@ import unittest
|
||||
import urllib3
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Error, Etcd3ClientError, RetryFailedError,\
|
||||
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, base64_encode
|
||||
from patroni.dcs.etcd import DnsCachingResolver
|
||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3Client, Etcd3Error, Etcd3ClientError, RetryFailedError,\
|
||||
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode, Etcd3
|
||||
from threading import Thread
|
||||
|
||||
from . import SleepException, MockResponse
|
||||
@@ -33,6 +34,8 @@ def mock_urlopen(self, method, url, **kwargs):
|
||||
"value": base64_encode('foo'), "lease": "bla", "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/members/foo'),
|
||||
"value": base64_encode('{}'), "lease": "123", "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/members/bar'),
|
||||
"value": base64_encode('{"version":"1.6.5"}'), "lease": "123", "mod_revision": '1'},
|
||||
{"key": base64_encode('/patroni/test/failover'), "value": base64_encode('{}'), "mod_revision": '1'}
|
||||
]
|
||||
})
|
||||
@@ -55,6 +58,16 @@ def mock_urlopen(self, method, url, **kwargs):
|
||||
return ret
|
||||
|
||||
|
||||
class TestEtcd3Client(unittest.TestCase):
|
||||
|
||||
@patch.object(Thread, 'start', Mock())
|
||||
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
|
||||
def test_authenticate(self):
|
||||
etcd3 = Etcd3Client({'host': '127.0.0.1', 'port': 2379, 'use_proxies': True, 'retry_timeout': 10},
|
||||
DnsCachingResolver())
|
||||
self.assertIsNotNone(etcd3._cluster_version)
|
||||
|
||||
|
||||
class BaseTestEtcd3(unittest.TestCase):
|
||||
|
||||
@patch.object(Thread, 'start', Mock())
|
||||
@@ -90,7 +103,7 @@ class TestKVCache(BaseTestEtcd3):
|
||||
|
||||
class TestPatroniEtcd3Client(BaseTestEtcd3):
|
||||
|
||||
@patch('patroni.dcs.etcd3.Etcd3Client.authenticate', Mock(side_effect=Exception))
|
||||
@patch('patroni.dcs.etcd3.Etcd3Client.authenticate', Mock(side_effect=AuthFailed))
|
||||
def test__init__(self):
|
||||
self.assertRaises(SystemExit, self.setUp)
|
||||
|
||||
@@ -172,6 +185,22 @@ class TestEtcd3(BaseTestEtcd3):
|
||||
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
|
||||
self.client._kv_cache = None
|
||||
with patch.object(urllib3.PoolManager, 'urlopen') as mock_urlopen:
|
||||
mock_urlopen.return_value = MockResponse()
|
||||
mock_urlopen.return_value.content = json.dumps({
|
||||
"header": {"revision": "1"},
|
||||
"kvs": [
|
||||
{"key": base64_encode('/patroni/test/status'),
|
||||
"value": base64_encode('{"optime":1234567,"slots":{"ls":12345}}'), "mod_revision": '1'}
|
||||
]
|
||||
})
|
||||
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
|
||||
mock_urlopen.return_value.content = json.dumps({
|
||||
"header": {"revision": "1"},
|
||||
"kvs": [
|
||||
{"key": base64_encode('/patroni/test/status'), "value": base64_encode('{'), "mod_revision": '1'}
|
||||
]
|
||||
})
|
||||
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
|
||||
mock_urlopen.side_effect = UnsupportedEtcdVersion('')
|
||||
self.assertRaises(UnsupportedEtcdVersion, self.etcd3.get_cluster)
|
||||
mock_urlopen.side_effect = SleepException()
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestExhibitor(unittest.TestCase):
|
||||
|
||||
@patch('urllib3.PoolManager.request', Mock(return_value=urllib3.HTTPResponse(
|
||||
status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}')))
|
||||
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
|
||||
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
||||
|
||||
+119
-46
@@ -3,7 +3,7 @@ import etcd
|
||||
import os
|
||||
import sys
|
||||
|
||||
from mock import call, Mock, MagicMock, PropertyMock, patch, mock_open
|
||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||
from patroni.config import Config
|
||||
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, SyncState, TimelineHistory
|
||||
from patroni.dcs.etcd import AbstractEtcdClientWithFailover
|
||||
@@ -35,10 +35,10 @@ def false(*args, **kwargs):
|
||||
|
||||
def get_cluster(initialize, leader, members, failover, sync, cluster_config=None):
|
||||
t = datetime.datetime.now().isoformat()
|
||||
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '"]]',
|
||||
[(1, 67197376, 'no recovery target specified', t)])
|
||||
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '","foo"]]',
|
||||
[(1, 67197376, 'no recovery target specified', t, 'foo')])
|
||||
cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1)
|
||||
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history)
|
||||
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history, None)
|
||||
|
||||
|
||||
def get_cluster_not_initialized_without_leader(cluster_config=None):
|
||||
@@ -66,7 +66,7 @@ def get_cluster_initialized_with_leader(failover=None, sync=None):
|
||||
|
||||
def get_cluster_initialized_with_only_leader(failover=None, cluster_config=None):
|
||||
leader = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
|
||||
return get_cluster(True, leader, [leader], failover, None, cluster_config)
|
||||
return get_cluster(True, leader, [leader.member], failover, None, cluster_config)
|
||||
|
||||
|
||||
def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
|
||||
@@ -152,13 +152,14 @@ def run_async(self, func, args=()):
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster()))
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'timeline_wal_position', Mock(return_value=(1, 10, 1)))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=3))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=10))
|
||||
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={
|
||||
'Database system identifier': SYSID,
|
||||
'Database cluster state': 'shut down',
|
||||
'Latest checkpoint location': '0/12345678'}))
|
||||
@patch.object(SlotsHandler, 'sync_replication_slots', Mock())
|
||||
'Latest checkpoint location': '0/12345678',
|
||||
"Latest checkpoint's TimeLineID": '2'}))
|
||||
@patch.object(SlotsHandler, 'load_replication_slots', Mock(side_effect=Exception))
|
||||
@patch.object(ConfigHandler, 'append_pg_hba', Mock())
|
||||
@patch.object(ConfigHandler, 'write_pgpass', Mock(return_value={}))
|
||||
@patch.object(ConfigHandler, 'write_recovery_conf', Mock())
|
||||
@@ -200,7 +201,8 @@ class TestHa(PostgresInit):
|
||||
|
||||
def test_update_lock(self):
|
||||
self.p.last_operation = Mock(side_effect=PostgresConnectionException(''))
|
||||
self.assertTrue(self.ha.update_lock(True))
|
||||
self.ha.dcs.update_leader = Mock(side_effect=Exception)
|
||||
self.assertFalse(self.ha.update_lock(True))
|
||||
|
||||
@patch.object(Postgresql, 'received_timeline', Mock(return_value=None))
|
||||
def test_touch_member(self):
|
||||
@@ -249,6 +251,15 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.assertEqual(self.ha.run_cycle(), 'failed to start postgres')
|
||||
|
||||
def test_recover_raft(self):
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in recovery', 'Database system identifier': SYSID}
|
||||
self.p.is_running = false
|
||||
self.p.follow = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.p.is_running = true
|
||||
self.ha.dcs.__class__.__name__ = 'Raft'
|
||||
self.assertEqual(self.ha.run_cycle(), 'started as a secondary')
|
||||
|
||||
def test_recover_former_master(self):
|
||||
self.p.follow = false
|
||||
self.p.is_running = false
|
||||
@@ -260,16 +271,34 @@ class TestHa(PostgresInit):
|
||||
|
||||
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
|
||||
def test_crash_recovery(self):
|
||||
self.ha.has_lock = true
|
||||
self.p.is_running = false
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
|
||||
self.assertEqual(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)),\
|
||||
patch.object(Ha, 'check_timeline', Mock(return_value=False)):
|
||||
self.ha._async_executor.schedule('doing crash recovery in a single user mode')
|
||||
self.ha.state_handler.cancellable._process = Mock()
|
||||
self.ha._crash_recovery_started -= 600
|
||||
self.ha.patroni.config.set_dynamic_configuration({'maximum_lag_on_failover': 10})
|
||||
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
|
||||
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
|
||||
def test_recover_with_rewind(self):
|
||||
self.p.is_running = false
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
|
||||
self.ha.cluster.leader.member.data.update(version='2.0.2', role='master')
|
||||
self.ha._rewind.pg_rewind = true
|
||||
self.ha._rewind.check_leader_is_not_in_recovery = true
|
||||
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True)):
|
||||
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
|
||||
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=False)):
|
||||
self.p.follow = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.p.is_running = true
|
||||
self.ha.follow = Mock(return_value='fake')
|
||||
self.assertEqual(self.ha.run_cycle(), 'fake')
|
||||
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Bootstrap, 'create_replica', Mock(return_value=1))
|
||||
@@ -291,7 +320,7 @@ class TestHa(PostgresInit):
|
||||
self.p.is_healthy = true
|
||||
self.ha.has_lock = true
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
def test_acquire_lock_as_master(self):
|
||||
@@ -320,7 +349,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.has_lock = true
|
||||
self.p.is_leader = false
|
||||
self.p.set_role('master')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
|
||||
def test_demote_after_failing_to_obtain_lock(self):
|
||||
self.ha.acquire_lock = false
|
||||
@@ -345,7 +374,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.has_lock = true
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock')
|
||||
|
||||
def test_promote_without_watchdog(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
@@ -360,12 +389,12 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
|
||||
def test_demote_because_not_having_lock(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)):
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because I do not have the lock and I was a leader')
|
||||
|
||||
def test_demote_because_update_lock_failed(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
@@ -375,20 +404,28 @@ class TestHa(PostgresInit):
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
||||
|
||||
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000))
|
||||
def test_follow(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
|
||||
self.ha.patroni.replicatefrom = "foo"
|
||||
self.p.config.check_recovery_conf = Mock(return_value=(True, False))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
self.ha.cluster.config.data.update({'slots': {'l': {'database': 'a', 'plugin': 'b'}}})
|
||||
self.ha.cluster.members[1].data['tags']['replicatefrom'] = 'postgresql0'
|
||||
self.ha.patroni.nofailover = True
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
|
||||
del self.ha.cluster.config.data['slots']
|
||||
self.ha.cluster.config.data.update({'postgresql': {'use_slots': False}})
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
|
||||
del self.ha.cluster.config.data['postgresql']['use_slots']
|
||||
|
||||
def test_follow_in_pause(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.is_paused = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action')
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0)')
|
||||
|
||||
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
|
||||
@@ -492,31 +529,34 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.run_cycle(), 'lost leader lock during restart')
|
||||
mock_terminate.assert_called()
|
||||
|
||||
self.ha.is_paused = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: restart in progress')
|
||||
|
||||
def test_manual_failover_from_leader(self):
|
||||
self.ha.fetch_node_status = get_node_status()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
f = Failover(0, self.p.name, '', None)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||
self.ha._rewind.rewind_or_reinitialize_needed_and_possible = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||
self.ha.fetch_node_status = get_node_status(nofailover=True)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(watchdog_failed=True)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(timeline=1)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(wal_position=1)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
|
||||
# Failover scheduled time must include timezone
|
||||
scheduled = datetime.datetime.now()
|
||||
@@ -525,28 +565,28 @@ class TestHa(PostgresInit):
|
||||
|
||||
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = scheduled + datetime.timedelta(seconds=30)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = scheduled + datetime.timedelta(seconds=-600)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = None
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
def test_manual_failover_from_leader_in_pause(self):
|
||||
self.ha.has_lock = true
|
||||
self.ha.is_paused = true
|
||||
scheduled = datetime.datetime.now()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEqual('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('PAUSE: no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
|
||||
self.assertEqual('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('PAUSE: no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
def test_manual_failover_from_leader_in_synchronous_mode(self):
|
||||
self.p.is_leader = true
|
||||
@@ -555,7 +595,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.is_failover_possible = false
|
||||
self.ha.process_sync_replication = Mock()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None))
|
||||
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle())
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a'))
|
||||
self.ha.is_failover_possible = true
|
||||
self.assertEqual('manual failover: demoting myself', self.ha.run_cycle())
|
||||
@@ -622,7 +662,7 @@ class TestHa(PostgresInit):
|
||||
# in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us
|
||||
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
with patch('patroni.postgresql.Postgresql.timeline_wal_position', return_value=(1, 1, 1)):
|
||||
with patch('patroni.postgresql.Postgresql.last_operation', return_value=1):
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=1):
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
@@ -661,7 +701,7 @@ class TestHa(PostgresInit):
|
||||
|
||||
def test_evaluate_scheduled_restart(self):
|
||||
self.p.postmaster_start_time = Mock(return_value=str(postmaster_start_time))
|
||||
# restart already in progres
|
||||
# restart already in progress
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
|
||||
self.assertIsNone(self.ha.evaluate_scheduled_restart())
|
||||
# restart while the postmaster has been already restarted, fails
|
||||
@@ -716,7 +756,7 @@ class TestHa(PostgresInit):
|
||||
self.p.config.check_recovery_conf = Mock(return_value=(False, False))
|
||||
self.ha._leader_timeline = 1
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the standby leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (leader) the standby leader with the lock')
|
||||
self.p.set_role('replica')
|
||||
self.p.config.check_recovery_conf = Mock(return_value=(True, False))
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
|
||||
@@ -725,7 +765,8 @@ class TestHa(PostgresInit):
|
||||
self.p.is_leader = false
|
||||
self.p.name = 'replica'
|
||||
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a standby leader')
|
||||
self.assertEqual(self.ha.run_cycle(),
|
||||
'no action. I am a secondary (replica) and following a standby leader (leader)')
|
||||
with patch.object(Leader, 'conn_url', PropertyMock(return_value='')):
|
||||
self.assertEqual(self.ha.run_cycle(), 'continue following the old known standby leader')
|
||||
|
||||
@@ -818,7 +859,8 @@ class TestHa(PostgresInit):
|
||||
|
||||
self.ha.has_lock = false
|
||||
self.p.is_leader = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
self.assertEqual(self.ha.run_cycle(),
|
||||
'no action. I am a secondary (postgresql0) and following a leader (leader)')
|
||||
check_calls([(update_lock, False), (demote, False)])
|
||||
|
||||
def test_manual_failover_while_starting(self):
|
||||
@@ -901,9 +943,8 @@ class TestHa(PostgresInit):
|
||||
self.p.pick_synchronous_standby = Mock(return_value=(['other2', 'other3'], ['other2']))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||
self.ha.run_cycle()
|
||||
# mock_set_sync.assert_called_once_with(['other2'])
|
||||
calls = [call(['other2']), call(['other2', 'other3'])]
|
||||
mock_set_sync.assert_has_calls(calls)
|
||||
self.assertEqual(mock_set_sync.call_args_list[0][0], (['other2'],))
|
||||
self.assertEqual(mock_set_sync.call_args_list[1][0], (['other2', 'other3'],))
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
# Test sync standby is not disabled when updating dcs fails
|
||||
@@ -1044,7 +1085,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster.config.data.clear()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
|
||||
def test_watch(self):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
@@ -1079,7 +1120,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
for tl in (1, 3):
|
||||
self.p.get_master_timeline = Mock(return_value=tl)
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
|
||||
@patch('sys.exit', return_value=1)
|
||||
def test_abort_join(self, exit_mock):
|
||||
@@ -1092,18 +1133,19 @@ class TestHa(PostgresInit):
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.is_paused = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0) the leader with the lock')
|
||||
self.ha.is_paused = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
def test_permanent_logical_slots_after_promote(self):
|
||||
config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1)
|
||||
self.p.name = 'other'
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config)
|
||||
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(leader=True, cluster_config=config)
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (other) the leader with the lock')
|
||||
|
||||
@patch.object(Cluster, 'has_member', true)
|
||||
def test_run_cycle(self):
|
||||
@@ -1111,3 +1153,34 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.run_cycle(), 'Unexpected exception raised, please report it as a BUG')
|
||||
self.ha.dcs.touch_member = Mock(side_effect=PatroniFatalException('foo'))
|
||||
self.assertRaises(PatroniFatalException, self.ha.run_cycle)
|
||||
|
||||
def test_empty_directory_in_pause(self):
|
||||
self.ha.is_paused = true
|
||||
self.p.data_directory_empty = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: running with empty data directory')
|
||||
self.assertEqual(self.p.role, 'uninitialized')
|
||||
|
||||
@patch('patroni.ha.Ha.sysid_valid', MagicMock(return_value=True))
|
||||
def test_sysid_no_match_in_pause(self):
|
||||
self.ha.is_paused = true
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in recovery', 'Database system identifier': '123'}
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: released leader key voluntarily due to the system ID mismatch')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('shutil.rmtree', Mock())
|
||||
@patch('os.makedirs', Mock())
|
||||
@patch('os.open', Mock())
|
||||
@patch('os.fsync', Mock())
|
||||
@patch('os.close', Mock())
|
||||
@patch('os.rename', Mock())
|
||||
@patch('patroni.postgresql.Postgresql.is_starting', Mock(return_value=False))
|
||||
@patch.object(builtins, 'open', mock_open())
|
||||
@patch.object(SlotsHandler, 'sync_replication_slots', Mock(return_value=['foo']))
|
||||
def test_follow_copy(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.p.is_leader = false
|
||||
self.assertTrue(self.ha.run_cycle().startswith('Copying logical slots'))
|
||||
|
||||
+20
-15
@@ -16,7 +16,8 @@ def mock_list_namespaced_config_map(*args, **kwargs):
|
||||
metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config',
|
||||
'annotations': {'initialize': '123', 'config': '{}'}}
|
||||
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
|
||||
metadata.update({'name': 'test-leader', 'annotations': {'optime': '1234', 'leader': 'p-0', 'ttl': '30s'}})
|
||||
metadata.update({'name': 'test-leader',
|
||||
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-failover', 'annotations': {'leader': 'p-0'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
@@ -26,7 +27,7 @@ def mock_list_namespaced_config_map(*args, **kwargs):
|
||||
return k8s_client.V1ConfigMapList(metadata=metadata, items=items, kind='ConfigMapList')
|
||||
|
||||
|
||||
def mock_list_namespaced_endpoints(*args, **kwargs):
|
||||
def mock_read_namespaced_endpoints(*args, **kwargs):
|
||||
target_ref = k8s_client.V1ObjectReference(kind='Pod', resource_version='10', name='p-0',
|
||||
namespace='default', uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d')
|
||||
address0 = k8s_client.V1EndpointAddress(ip='10.0.0.0', target_ref=target_ref)
|
||||
@@ -35,9 +36,12 @@ def mock_list_namespaced_endpoints(*args, **kwargs):
|
||||
subset = k8s_client.V1EndpointSubset(addresses=[address1, address0], ports=[port])
|
||||
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b'}, name='test',
|
||||
annotations={'optime': '1234', 'leader': 'p-0', 'ttl': '30s'})
|
||||
endpoint = k8s_client.V1Endpoints(subsets=[subset], metadata=metadata)
|
||||
metadata = k8s_client.V1ObjectMeta(resource_version='1')
|
||||
return k8s_client.V1EndpointsList(metadata=metadata, items=[endpoint], kind='V1EndpointsList')
|
||||
return k8s_client.V1Endpoints(subsets=[subset], metadata=metadata)
|
||||
|
||||
|
||||
def mock_list_namespaced_endpoints(*args, **kwargs):
|
||||
return k8s_client.V1EndpointsList(metadata=k8s_client.V1ObjectMeta(resource_version='1'),
|
||||
items=[mock_read_namespaced_endpoints()], kind='V1EndpointsList')
|
||||
|
||||
|
||||
def mock_list_namespaced_pod(*args, **kwargs):
|
||||
@@ -257,24 +261,25 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1'
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
|
||||
def test_update_leader_with_restricted_access(self):
|
||||
self.assertIsNotNone(self.k.update_leader('123', True))
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'read_namespaced_endpoints', create=True)
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
|
||||
def test__update_leader_with_retry(self, mock_patch):
|
||||
def test__update_leader_with_retry(self, mock_patch, mock_read):
|
||||
mock_read.return_value = mock_read_namespaced_endpoints()
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(502, '')
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
mock_patch.side_effect = RetryFailedError('')
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(409, '')
|
||||
with patch('time.time', Mock(side_effect=[0, 100, 200])):
|
||||
with patch('time.time', Mock(side_effect=[0, 100, 200, 0, 0, 0, 0, 100, 200])):
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
with patch('time.sleep', Mock()):
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
mock_patch.side_effect = [k8s_client.rest.ApiException(409, ''), mock_namespaced_kind()]
|
||||
self.k._kinds._object_cache['test'].metadata.resource_version = '2'
|
||||
self.assertIsNotNone(self.k._update_leader_with_retry({}, '1', []))
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
mock_patch.side_effect = [k8s_client.rest.ApiException(409, ''), mock_namespaced_kind()]
|
||||
mock_read.return_value.metadata.resource_version = '2'
|
||||
self.assertIsNotNone(self.k._update_leader_with_retry({}, '1', []))
|
||||
mock_patch.side_effect = k8s_client.rest.ApiException(409, '')
|
||||
mock_read.side_effect = Exception
|
||||
self.assertFalse(self.k.update_leader('123'))
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints',
|
||||
Mock(side_effect=[k8s_client.rest.ApiException(500, ''),
|
||||
|
||||
@@ -63,3 +63,12 @@ class TestPatroniLogger(unittest.TestCase):
|
||||
self.assertRaises(Exception, logger.shutdown)
|
||||
self.assertLessEqual(logger.queue_size, 2) # "Failed to close the old log handler" could be still in the queue
|
||||
self.assertEqual(logger.records_lost, 0)
|
||||
|
||||
def test_interceptor(self):
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config({'level': 'INFO'})
|
||||
logger.start()
|
||||
_LOG.info('Lock owner: ')
|
||||
_LOG.info('blabla')
|
||||
logger.shutdown()
|
||||
self.assertEqual(logger.records_lost, 0)
|
||||
|
||||
+74
-46
@@ -1,4 +1,4 @@
|
||||
import mock # for the mock.call method, importing it without a namespace breaks python3
|
||||
import datetime
|
||||
import os
|
||||
import psutil
|
||||
import psycopg2
|
||||
@@ -8,11 +8,11 @@ import time
|
||||
|
||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||
from patroni.async_executor import CriticalTask
|
||||
from patroni.dcs import Cluster, ClusterConfig, Member, RemoteMember, SyncState
|
||||
from patroni.dcs import Cluster, RemoteMember, SyncState
|
||||
from patroni.exceptions import PostgresConnectionException, PatroniException
|
||||
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
||||
from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.postmaster import PostmasterProcess
|
||||
from patroni.postgresql.slots import SlotsHandler
|
||||
from patroni.utils import RetryFailedError
|
||||
from six.moves import builtins
|
||||
from threading import Thread, current_thread
|
||||
@@ -224,6 +224,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch('patroni.postgresql.config.mtime', mock_mtime)
|
||||
@patch('patroni.postgresql.config.ConfigHandler._get_pg_settings')
|
||||
def test_check_recovery_conf(self, mock_get_pg_settings):
|
||||
self.p.call_nowait('on_start')
|
||||
mock_get_pg_settings.return_value = {
|
||||
'primary_conninfo': ['primary_conninfo', 'foo=', None, 'string', 'postmaster', self.p.config._auto_conf],
|
||||
'recovery_min_apply_delay': ['recovery_min_apply_delay', '0', 'ms', 'integer', 'sighup', 'foo']
|
||||
@@ -259,6 +260,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(MockPostmaster, 'create_time', Mock(return_value=1234567), create=True)
|
||||
@patch('patroni.postgresql.config.ConfigHandler._get_pg_settings')
|
||||
def test__read_recovery_params(self, mock_get_pg_settings):
|
||||
self.p.call_nowait('on_start')
|
||||
mock_get_pg_settings.return_value = {'primary_conninfo': ['primary_conninfo', '', None, 'string',
|
||||
'postmaster', self.p.config._postgresql_conf]}
|
||||
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': {'password': 'foo'}})
|
||||
@@ -302,28 +304,6 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}})
|
||||
self.p.follow(m)
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_sync_replication_slots(self):
|
||||
self.p.start()
|
||||
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
|
||||
'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}}}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None)
|
||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=True)),\
|
||||
patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
|
||||
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
cluster.members.extend([alias1, alias2])
|
||||
self.p.slots_handler.sync_replication_slots(cluster)
|
||||
self.assertEqual(errorlog_mock.call_count, 5)
|
||||
ca = errorlog_mock.call_args_list[0][0][1]
|
||||
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
|
||||
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
|
||||
|
||||
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
|
||||
def test__query(self):
|
||||
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
|
||||
@@ -338,14 +318,41 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, 'pg_isready', Mock(return_value=STATE_REJECT))
|
||||
def test_is_leader(self):
|
||||
self.assertTrue(self.p.is_leader())
|
||||
self.p.reset_cluster_info_state()
|
||||
self.p.reset_cluster_info_state(None)
|
||||
with patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))):
|
||||
self.assertRaises(PostgresConnectionException, self.p.is_leader)
|
||||
|
||||
@patch.object(Postgresql, 'controldata',
|
||||
Mock(return_value={'Database cluster state': 'shut down', 'Latest checkpoint location': 'X/678'}))
|
||||
def test_latest_checkpoint_location(self):
|
||||
self.assertIsNone(self.p.latest_checkpoint_location())
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down',
|
||||
'Latest checkpoint location': '0/1ADBC18',
|
||||
"Latest checkpoint's TimeLineID": '1'}))
|
||||
@patch('subprocess.Popen')
|
||||
def test_latest_checkpoint_location(self, mock_popen):
|
||||
mock_popen.return_value.communicate.return_value = (None, None)
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), '28163096')
|
||||
# 9.3 and 9.4 format
|
||||
mock_popen.return_value.communicate.side_effect = [
|
||||
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
|
||||
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
|
||||
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
|
||||
(b'rmgr: Transaction len (rec/tot): 64/ 96, tx: 726, lsn: 0/01ADBBB8, prev 0/01ADBB70, ' +
|
||||
b'bkp: 0000, desc: commit: 2021-02-26 11:19:37.900918 CET; inval msgs: catcache 11 catcache 10', None)]
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), '28163096')
|
||||
mock_popen.return_value.communicate.side_effect = [
|
||||
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
|
||||
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
|
||||
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
|
||||
(b'rmgr: XLOG len (rec/tot): 0/ 32, tx: 0, lsn: 0/01ADBBB8, prev 0/01ADBBA0, ' +
|
||||
b'bkp: 0000, desc: xlog switch ', None)]
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), '28163000')
|
||||
# 9.5+ format
|
||||
mock_popen.return_value.communicate.side_effect = [
|
||||
(b'rmgr: XLOG len (rec/tot): 114/ 114, tx: 0, lsn: 0/01ADBC18, prev 0/018260F8, ' +
|
||||
b'desc: CHECKPOINT_SHUTDOWN redo 0/1825ED8; tli 1; prev tli 1; fpw true; xid 0:494; oid 16387; multi 1' +
|
||||
b'; offset 0; oldest xid 479 in DB 1; oldest multi 1 in DB 1; oldest/newest commit timestamp xid: 0/0;' +
|
||||
b' oldest running xid 0; shutdown', None),
|
||||
(b'rmgr: XLOG len (rec/tot): 24/ 24, tx: 0, lsn: 0/018260F8, prev 0/01826080, ' +
|
||||
b'desc: SWITCH ', None)]
|
||||
self.assertEqual(self.p.latest_checkpoint_location(), '25321720')
|
||||
|
||||
def test_reload(self):
|
||||
self.assertTrue(self.p.reload())
|
||||
@@ -497,7 +504,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
parameters = self._PARAMETERS.copy()
|
||||
parameters.pop('f.oo')
|
||||
parameters['wal_buffers'] = '512'
|
||||
config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'authentication': {},
|
||||
config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'use_unix_socket_repl': True,
|
||||
'authentication': {},
|
||||
'retry_timeout': 10, 'listen': '*', 'krbsrvname': 'postgres', 'parameters': parameters}
|
||||
self.p.reload_config(config)
|
||||
mock_fetchone.side_effect = Exception
|
||||
@@ -513,6 +521,14 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.p.reload_config(config)
|
||||
self.p.config.resolve_connection_addresses()
|
||||
|
||||
def test_resolve_connection_addresses(self):
|
||||
self.p.config._config['use_unix_socket'] = self.p.config._config['use_unix_socket_repl'] = True
|
||||
self.p.config.resolve_connection_addresses()
|
||||
self.assertEqual(self.p.config.local_replication_address, {'host': '/tmp', 'port': '5432'})
|
||||
self.p.config._server_parameters.pop('unix_socket_directories')
|
||||
self.p.config.resolve_connection_addresses()
|
||||
self.assertEqual(self.p.config._local_address, {'port': '5432'})
|
||||
|
||||
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
|
||||
def test_get_major_version(self):
|
||||
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
|
||||
@@ -521,8 +537,9 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertEqual(self.p.get_major_version(), 0)
|
||||
|
||||
def test_postmaster_start_time(self):
|
||||
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))):
|
||||
self.assertEqual(self.p.postmaster_start_time(), 'foo')
|
||||
now = datetime.datetime.now()
|
||||
with patch.object(MockCursor, "fetchone", Mock(return_value=(now, True, '', '', '', '', False))):
|
||||
self.assertEqual(self.p.postmaster_start_time(), now.isoformat(sep=' '))
|
||||
t = Thread(target=self.p.postmaster_start_time)
|
||||
t.start()
|
||||
t.join()
|
||||
@@ -608,38 +625,38 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
|
||||
def test_pick_sync_standby(self):
|
||||
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
|
||||
SyncState(0, self.me.name, self.leadermem.name), None)
|
||||
SyncState(0, self.me.name, self.leadermem.name), None, None)
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.fetchone.return_value = ('remote_apply',)
|
||||
|
||||
with patch.object(Postgresql, "query", side_effect=[
|
||||
mock_cursor,
|
||||
[(self.leadermem.name, 'streaming', 'sync'),
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async')]
|
||||
[(self.leadermem.name, 'sync', 1),
|
||||
(self.me.name, 'async', 2),
|
||||
(self.other.name, 'async', 2)]
|
||||
]):
|
||||
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.leadermem.name], [self.leadermem.name]))
|
||||
|
||||
with patch.object(Postgresql, "query", side_effect=[
|
||||
mock_cursor,
|
||||
[(self.leadermem.name, 'streaming', 'potential'),
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async')]
|
||||
[(self.leadermem.name, 'potential', 1),
|
||||
(self.me.name, 'async', 2),
|
||||
(self.other.name, 'async', 2)]
|
||||
]):
|
||||
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.leadermem.name], []))
|
||||
|
||||
with patch.object(Postgresql, "query", side_effect=[
|
||||
mock_cursor,
|
||||
[(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async')]
|
||||
[(self.me.name, 'async', 1),
|
||||
(self.other.name, 'async', 2)]
|
||||
]):
|
||||
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.me.name], []))
|
||||
|
||||
with patch.object(Postgresql, "query", side_effect=[
|
||||
mock_cursor,
|
||||
[('missing', 'streaming', 'sync'),
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async')]
|
||||
[('missing', 'sync', 1),
|
||||
(self.me.name, 'async', 2),
|
||||
(self.other.name, 'async', 3)]
|
||||
]):
|
||||
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.me.name], []))
|
||||
|
||||
@@ -706,6 +723,8 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertEqual(self.p.get_master_timeline(), 1)
|
||||
|
||||
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
|
||||
@patch.object(Bootstrap, 'running_custom_bootstrap', PropertyMock(return_value=True))
|
||||
@patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True))
|
||||
def test__build_effective_configuration(self):
|
||||
with patch.object(Postgresql, 'controldata',
|
||||
Mock(return_value={'max_connections setting': '200',
|
||||
@@ -724,10 +743,19 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError('')))
|
||||
def test_received_timeline(self):
|
||||
self.p.set_role('standby_leader')
|
||||
self.p.reset_cluster_info_state()
|
||||
self.p.reset_cluster_info_state(None)
|
||||
self.assertRaises(PostgresConnectionException, self.p.received_timeline)
|
||||
|
||||
def test__write_recovery_params(self):
|
||||
self.p.config._write_recovery_params(Mock(), {'pause_at_recovery_target': 'false'})
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90400)):
|
||||
self.p.config._write_recovery_params(Mock(), {'recovery_target_action': 'PROMOTE'})
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_set_enforce_hot_standby_feedback(self):
|
||||
self.p.set_enforce_hot_standby_feedback(True)
|
||||
|
||||
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=140000))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=True))
|
||||
def test_handle_parameter_change(self):
|
||||
self.p.handle_parameter_change()
|
||||
|
||||
+49
-42
@@ -1,14 +1,15 @@
|
||||
import os
|
||||
import unittest
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.raft import DynMemberSyncObj, KVStoreTTL, Raft, SyncObjUtility
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.raft import DynMemberSyncObj, KVStoreTTL, Raft, SyncObjUtility, TCPTransport, _TCPTransport
|
||||
from pysyncobj import SyncObjConf, FAIL_REASON
|
||||
|
||||
|
||||
def remove_files(prefix):
|
||||
for f in ('journal', 'dump'):
|
||||
for f in ('journal', 'journal.meta', 'dump'):
|
||||
f = prefix + f
|
||||
if os.path.isfile(f):
|
||||
for i in range(0, 15):
|
||||
@@ -22,6 +23,16 @@ def remove_files(prefix):
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
class TestTCPTransport(unittest.TestCase):
|
||||
|
||||
@patch.object(TCPTransport, '__init__', Mock())
|
||||
@patch.object(TCPTransport, 'setOnUtilityMessageCallback', Mock())
|
||||
@patch.object(TCPTransport, '_connectIfNecessarySingle', Mock(side_effect=Exception))
|
||||
def test__connectIfNecessarySingle(self):
|
||||
t = _TCPTransport(Mock(), None, [])
|
||||
self.assertFalse(t._connectIfNecessarySingle(None))
|
||||
|
||||
|
||||
@patch('pysyncobj.tcp_server.TcpServer.bind', Mock())
|
||||
class TestDynMemberSyncObj(unittest.TestCase):
|
||||
|
||||
@@ -30,50 +41,38 @@ class TestDynMemberSyncObj(unittest.TestCase):
|
||||
self.conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, autoTick=False)
|
||||
self.so = DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
|
||||
|
||||
@patch.object(SyncObjUtility, 'sendMessage')
|
||||
def test_add_member(self, mock_send_message):
|
||||
mock_send_message.return_value = [{'addr': '127.0.0.1:1235'}, {'addr': '127.0.0.1:1236'}]
|
||||
mock_send_message.ver = 0
|
||||
@patch.object(SyncObjUtility, 'executeCommand')
|
||||
def test_add_member(self, mock_execute_command):
|
||||
mock_execute_command.return_value = [{'addr': '127.0.0.1:1235'}, {'addr': '127.0.0.1:1236'}]
|
||||
mock_execute_command.ver = 0
|
||||
DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
|
||||
self.conf.dynamicMembershipChange = False
|
||||
DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
|
||||
|
||||
def test___onUtilityMessage(self):
|
||||
self.so._SyncObj__encryptor = Mock()
|
||||
def test_getMembers(self):
|
||||
mock_conn = Mock()
|
||||
mock_conn.sendRandKey = None
|
||||
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, 'randkey')
|
||||
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['members'])
|
||||
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['status'])
|
||||
|
||||
def test__SyncObj__doChangeCluster(self):
|
||||
self.so._SyncObj__doChangeCluster(['add', '127.0.0.1:1236'])
|
||||
|
||||
def test_utility(self):
|
||||
utility = SyncObjUtility(['127.0.0.1:1235'], self.conf)
|
||||
utility.setPartnerNode(list(utility._SyncObj__otherNodes)[0])
|
||||
utility.sendMessage(['members'])
|
||||
utility._onMessageReceived(0, '')
|
||||
|
||||
|
||||
@patch.object(SyncObjConf, 'fullDumpFile', PropertyMock(return_value=None), create=True)
|
||||
@patch.object(SyncObjConf, 'journalFile', PropertyMock(return_value=None), create=True)
|
||||
class TestKVStoreTTL(unittest.TestCase):
|
||||
|
||||
@patch.object(SyncObjConf, 'fullDumpFile', PropertyMock(return_value=None), create=True)
|
||||
@patch.object(SyncObjConf, 'journalFile', PropertyMock(return_value=None), create=True)
|
||||
def setUp(self):
|
||||
self.conf = SyncObjConf(appendEntriesUseBatch=False, appendEntriesPeriod=0.001,
|
||||
raftMinTimeout=0.004, raftMaxTimeout=0.005, autoTickPeriod=0.001)
|
||||
callback = Mock()
|
||||
callback.replicated = False
|
||||
self.so = KVStoreTTL('127.0.0.1:1234', [], self.conf, on_set=callback, on_delete=callback)
|
||||
self.so = KVStoreTTL(None, callback, callback, self_addr='127.0.0.1:1234')
|
||||
self.so.startAutoTick()
|
||||
self.so.set_retry_timeout(10)
|
||||
|
||||
@staticmethod
|
||||
def destroy(so):
|
||||
so.destroy()
|
||||
so._SyncObj__thread.join()
|
||||
|
||||
def tearDown(self):
|
||||
if self.so:
|
||||
self.destroy(self.so)
|
||||
self.so.destroy()
|
||||
|
||||
def test_set(self):
|
||||
self.assertTrue(self.so.set('foo', 'bar', prevExist=False, ttl=30))
|
||||
@@ -82,12 +81,10 @@ class TestKVStoreTTL(unittest.TestCase):
|
||||
self.assertTrue(self.so.retry(self.so._set, 'foo', {'value': 'buz', 'created': 1, 'updated': 1}))
|
||||
|
||||
def test_delete(self):
|
||||
self.conf.autoTickPeriod = 0.1
|
||||
self.so.autoTickPeriod = 0.2
|
||||
self.so.set('foo', 'bar')
|
||||
self.so.set('fooo', 'bar')
|
||||
self.assertFalse(self.so.delete('foo', prevValue='buz'))
|
||||
self.assertFalse(self.so.delete('foo', prevValue='bar', timeout=0.00001))
|
||||
self.assertFalse(self.so.delete('foo', prevValue='bar'))
|
||||
self.assertTrue(self.so.delete('foo', recursive=True))
|
||||
self.assertFalse(self.so.retry(self.so._delete, 'foo', prevValue=''))
|
||||
|
||||
@@ -99,38 +96,48 @@ class TestKVStoreTTL(unittest.TestCase):
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_retry(self):
|
||||
return_values = [FAIL_REASON.QUEUE_FULL, FAIL_REASON.SUCCESS, FAIL_REASON.REQUEST_DENIED]
|
||||
return_values = [FAIL_REASON.QUEUE_FULL] * 2 + [FAIL_REASON.SUCCESS, FAIL_REASON.REQUEST_DENIED]
|
||||
|
||||
def test(callback):
|
||||
callback(True, return_values.pop(0))
|
||||
|
||||
with patch('time.time', Mock(side_effect=[1, 100])):
|
||||
self.assertFalse(self.so.retry(test))
|
||||
|
||||
self.assertTrue(self.so.retry(test))
|
||||
self.assertFalse(self.so.retry(test))
|
||||
|
||||
def test_on_ready_override(self):
|
||||
self.assertTrue(self.so.set('foo', 'bar'))
|
||||
self.destroy(self.so)
|
||||
self.so.destroy()
|
||||
self.so = None
|
||||
self.conf.onReady = Mock()
|
||||
self.conf.autoTick = False
|
||||
so = KVStoreTTL('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
|
||||
so = KVStoreTTL(Mock(), None, None, self_addr='127.0.0.1:1234',
|
||||
partner_addrs=['127.0.0.1:1235'], patronictl=True)
|
||||
so.doTick(0)
|
||||
so.destroy()
|
||||
|
||||
|
||||
class TestRaft(unittest.TestCase):
|
||||
|
||||
_TMP = tempfile.gettempdir()
|
||||
|
||||
def test_raft(self):
|
||||
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234', 'retry_timeout': 10})
|
||||
raft.set_retry_timeout(20)
|
||||
raft.set_ttl(60)
|
||||
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234',
|
||||
'retry_timeout': 10, 'data_dir': self._TMP})
|
||||
raft.reload_config({'retry_timeout': 20, 'ttl': 60, 'loop_wait': 10})
|
||||
self.assertTrue(raft._sync_obj.set(raft.members_path + 'legacy', '{"version":"2.0.0"}'))
|
||||
self.assertTrue(raft.touch_member(''))
|
||||
self.assertTrue(raft.initialize())
|
||||
self.assertTrue(raft.cancel_initialization())
|
||||
self.assertTrue(raft.set_config_value('{}'))
|
||||
self.assertTrue(raft.write_sync_state('foo', 'bar'))
|
||||
self.assertTrue(raft.update_leader('1'))
|
||||
self.assertTrue(raft.manual_failover('foo', 'bar'))
|
||||
raft.get_cluster()
|
||||
self.assertTrue(raft._sync_obj.set(raft.status_path, '{"optime":1234567,"slots":{"ls":12345}}'))
|
||||
raft.get_cluster()
|
||||
self.assertTrue(raft.update_leader('1'))
|
||||
self.assertTrue(raft._sync_obj.set(raft.status_path, '{'))
|
||||
raft.get_cluster()
|
||||
self.assertTrue(raft.delete_sync_state())
|
||||
self.assertTrue(raft.delete_leader())
|
||||
self.assertTrue(raft.set_history_value(''))
|
||||
@@ -139,10 +146,9 @@ class TestRaft(unittest.TestCase):
|
||||
self.assertTrue(raft.take_leader())
|
||||
raft.watch(None, 0.001)
|
||||
raft._sync_obj.destroy()
|
||||
raft._sync_obj._SyncObj__thread.join()
|
||||
|
||||
def tearDown(self):
|
||||
remove_files('127.0.0.1:1234.')
|
||||
remove_files(os.path.join(self._TMP, '127.0.0.1:1234.'))
|
||||
|
||||
def setUp(self):
|
||||
self.tearDown()
|
||||
@@ -152,4 +158,5 @@ class TestRaft(unittest.TestCase):
|
||||
def test_init(self, mock_event, mock_kvstore):
|
||||
mock_kvstore.return_value.applied_local_log = False
|
||||
mock_event.return_value.isSet.side_effect = [False, True]
|
||||
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True}))
|
||||
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
|
||||
'self_addr': '1', 'data_dir': self._TMP}))
|
||||
|
||||
+11
-5
@@ -40,6 +40,12 @@ def mock_cancellable_call1(*args, **kwargs):
|
||||
return 1
|
||||
|
||||
|
||||
def mock_single_user_mode(self, communicate, options):
|
||||
communicate['stdout'] = b'foo'
|
||||
communicate['stderr'] = b'bar'
|
||||
return 1
|
||||
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
class TestRewind(BaseTestPostgresql):
|
||||
@@ -87,7 +93,7 @@ class TestRewind(BaseTestPostgresql):
|
||||
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
|
||||
|
||||
with patch.object(Postgresql, 'is_running', Mock(return_value=True)):
|
||||
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[(0, 0, 1, 1,), Exception])):
|
||||
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[(0, 0, 1, 1, 0, 0, 0, 0, 0, None), Exception])):
|
||||
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
|
||||
|
||||
@patch.object(CancellableSubprocess, 'call', mock_cancellable_call)
|
||||
@@ -170,8 +176,8 @@ class TestRewind(BaseTestPostgresql):
|
||||
|
||||
@patch.object(MockCursor, 'fetchone', Mock(side_effect=[(True,), Exception]))
|
||||
def test_check_leader_is_not_in_recovery(self):
|
||||
self.r.check_leader_is_not_in_recovery()
|
||||
self.r.check_leader_is_not_in_recovery()
|
||||
self.r.check_leader_is_not_in_recovery({})
|
||||
self.r.check_leader_is_not_in_recovery({})
|
||||
|
||||
def test_read_postmaster_opts(self):
|
||||
m = mock_open(read_data='/usr/lib/postgres/9.6/bin/postgres "-D" "data/postgresql0" \
|
||||
@@ -206,9 +212,9 @@ class TestRewind(BaseTestPostgresql):
|
||||
@patch('os.listdir', Mock(return_value=[]))
|
||||
@patch('os.path.isfile', Mock(return_value=True))
|
||||
@patch.object(Rewind, 'read_postmaster_opts', Mock(return_value={}))
|
||||
@patch.object(Rewind, 'single_user_mode', Mock(return_value=0))
|
||||
@patch.object(Rewind, 'single_user_mode', mock_single_user_mode)
|
||||
def test_ensure_clean_shutdown(self):
|
||||
self.assertTrue(self.r.ensure_clean_shutdown())
|
||||
self.assertIsNone(self.r.ensure_clean_shutdown())
|
||||
|
||||
@patch('patroni.postgresql.rewind.Thread', MockThread)
|
||||
@patch.object(Postgresql, 'controldata')
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import mock
|
||||
import os
|
||||
import psycopg2
|
||||
import unittest
|
||||
|
||||
|
||||
from mock import Mock, PropertyMock, patch
|
||||
|
||||
from patroni.dcs import Cluster, ClusterConfig, Member
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.slots import SlotsHandler, fsync_dir
|
||||
|
||||
from . import BaseTestPostgresql, psycopg2_connect, MockCursor
|
||||
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
class TestSlotsHandler(BaseTestPostgresql):
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('os.rename', Mock())
|
||||
@patch('patroni.postgresql.CallbackExecutor', Mock())
|
||||
@patch.object(Postgresql, 'get_major_version', Mock(return_value=130000))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def setUp(self):
|
||||
super(TestSlotsHandler, self).setUp()
|
||||
self.s = self.p.slots_handler
|
||||
self.p.start()
|
||||
|
||||
def test_sync_replication_slots(self):
|
||||
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
|
||||
'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}},
|
||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0,
|
||||
[self.me, self.other, self.leadermem], None, None, None, {'test_3': 10})
|
||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
self.p.set_role('standby_leader')
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
self.p.set_role('replica')
|
||||
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
self.p.set_role('master')
|
||||
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=True)),\
|
||||
patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
|
||||
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
cluster.members.extend([alias1, alias2])
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
self.assertEqual(errorlog_mock.call_count, 5)
|
||||
ca = errorlog_mock.call_args_list[0][0][1]
|
||||
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
|
||||
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90618)):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
|
||||
def test_process_permanent_slots(self):
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}},
|
||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None, None)
|
||||
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
with patch.object(Postgresql, '_query') as mock_query:
|
||||
self.p.reset_cluster_info_state(None)
|
||||
mock_query.return_value.fetchone.return_value = (
|
||||
1, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
[{"slot_name": "ls", "type": "logical", "datoid": 5, "plugin": "b",
|
||||
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])
|
||||
self.assertEqual(self.p.slots(), {'ls': 12345})
|
||||
|
||||
self.p.reset_cluster_info_state(None)
|
||||
mock_query.return_value.fetchone.return_value = (
|
||||
1, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
[{"slot_name": "ls", "type": "logical", "datoid": 6, "plugin": "b",
|
||||
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])
|
||||
self.assertEqual(self.p.slots(), {})
|
||||
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
|
||||
def test__ensure_logical_slots_replica(self):
|
||||
self.p.set_role('replica')
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0,
|
||||
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12346})
|
||||
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
|
||||
self.s._schedule_load_slots = False
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.errors.UndefinedFile)):
|
||||
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
|
||||
cluster.slots['ls'] = 'a'
|
||||
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
|
||||
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
|
||||
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
|
||||
|
||||
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
|
||||
def test_copy_logical_slots(self):
|
||||
self.s.copy_logical_slots(self.leader, ['foo'])
|
||||
|
||||
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
|
||||
def test_check_logical_slots_readiness(self):
|
||||
self.s.copy_logical_slots(self.leader, ['ls'])
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0,
|
||||
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12345})
|
||||
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
|
||||
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
|
||||
self.s.check_logical_slots_readiness(cluster, False, None)
|
||||
|
||||
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
|
||||
def test_on_promote(self):
|
||||
self.s.copy_logical_slots(self.leader, ['ls'])
|
||||
self.s.on_promote()
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', "Windows not supported")
|
||||
@patch('os.open', Mock())
|
||||
@patch('os.close', Mock())
|
||||
@patch('os.fsync', Mock(side_effect=OSError))
|
||||
def test_fsync_dir(self):
|
||||
self.assertRaises(OSError, fsync_dir, 'foo')
|
||||
+10
-1
@@ -2,7 +2,7 @@ import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.exceptions import PatroniException
|
||||
from patroni.utils import Retry, RetryFailedError, enable_keepalive, polling_loop, validate_directory
|
||||
from patroni.utils import Retry, RetryFailedError, enable_keepalive, find_executable, polling_loop, validate_directory
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@@ -41,6 +41,15 @@ class TestUtils(unittest.TestCase):
|
||||
with patch('sys.platform', platform):
|
||||
self.assertIsNone(enable_keepalive(Mock(), 10, 5))
|
||||
|
||||
@patch('sys.platform', 'win32')
|
||||
def test_find_executable(self):
|
||||
with patch('os.path.isfile', Mock(return_value=True)):
|
||||
self.assertEqual(find_executable('vim'), 'vim.exe')
|
||||
with patch('os.path.isfile', Mock(return_value=False)):
|
||||
self.assertIsNone(find_executable('vim'))
|
||||
with patch('os.path.isfile', Mock(side_effect=[False, True])):
|
||||
self.assertEqual(find_executable('vim', '/'), '/vim.exe')
|
||||
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
class TestRetrySleeper(unittest.TestCase):
|
||||
|
||||
+21
-9
@@ -33,11 +33,21 @@ config = {
|
||||
"etcd": {
|
||||
"hosts": "127.0.0.1:2379,127.0.0.1:2380"
|
||||
},
|
||||
"etcd3": {
|
||||
"url": "https://127.0.0.1:2379"
|
||||
},
|
||||
"exhibitor": {
|
||||
"hosts": ["string"],
|
||||
"port": 4000,
|
||||
"pool_interval": 1000
|
||||
},
|
||||
"raft": {
|
||||
"self_addr": "127.0.0.1:2222",
|
||||
"bind_addr": "0.0.0.0:2222",
|
||||
"partner_addrs": ["127.0.0.1:2223", "127.0.0.1:2224"],
|
||||
"data_dir": "/",
|
||||
"password": "12345"
|
||||
},
|
||||
"zookeeper": {
|
||||
"hosts": "127.0.0.1:3379,127.0.0.1:3380"
|
||||
},
|
||||
@@ -139,7 +149,7 @@ class TestValidator(unittest.TestCase):
|
||||
def test_complete_config(self, mock_out, mock_err):
|
||||
schema(config)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual(['postgresql.bin_dir'], parse_output(output))
|
||||
self.assertEqual(['postgresql.bin_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
|
||||
|
||||
def test_bin_dir_is_file(self, mock_out, mock_err):
|
||||
files.append(config["postgresql"]["data_dir"])
|
||||
@@ -151,7 +161,8 @@ class TestValidator(unittest.TestCase):
|
||||
schema(c)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual(['etcd.hosts.1', 'etcd.hosts.2', 'kubernetes.pod_ip', 'postgresql.bin_dir',
|
||||
'postgresql.data_dir', 'restapi.connect_address'], parse_output(output))
|
||||
'postgresql.data_dir', 'raft.bind_addr', 'raft.self_addr',
|
||||
'restapi.connect_address'], parse_output(output))
|
||||
|
||||
@patch('socket.inet_pton', Mock(), create=True)
|
||||
def test_bin_dir_is_empty(self, mock_out, mock_err):
|
||||
@@ -167,8 +178,8 @@ class TestValidator(unittest.TestCase):
|
||||
with patch('patroni.validator.open', mock_open(read_data='9')):
|
||||
schema(c)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual(['consul.host', 'etcd.host', 'postgresql.bin_dir', 'postgresql.data_dir',
|
||||
'postgresql.listen', 'restapi.connect_address'], parse_output(output))
|
||||
self.assertEqual(['consul.host', 'etcd.host', 'postgresql.bin_dir', 'postgresql.data_dir', 'postgresql.listen',
|
||||
'raft.bind_addr', 'raft.self_addr', 'restapi.connect_address'], parse_output(output))
|
||||
|
||||
@patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 12.1"))
|
||||
def test_data_dir_contains_pg_version(self, mock_out, mock_err):
|
||||
@@ -186,7 +197,7 @@ class TestValidator(unittest.TestCase):
|
||||
with patch('patroni.validator.open', mock_open(read_data='12')):
|
||||
schema(config)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual([], parse_output(output))
|
||||
self.assertEqual(['raft.bind_addr', 'raft.self_addr'], parse_output(output))
|
||||
|
||||
@patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 12.1"))
|
||||
def test_pg_version_missmatch(self, mock_out, mock_err):
|
||||
@@ -201,7 +212,8 @@ class TestValidator(unittest.TestCase):
|
||||
with patch('patroni.validator.open', mock_open(read_data='11')):
|
||||
schema(c)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual(['etcd.hosts', 'postgresql.data_dir'], parse_output(output))
|
||||
self.assertEqual(['etcd.hosts', 'postgresql.data_dir',
|
||||
'raft.bind_addr', 'raft.self_addr'], parse_output(output))
|
||||
|
||||
@patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 12.1"))
|
||||
def test_pg_wal_doesnt_exist(self, mock_out, mock_err):
|
||||
@@ -214,7 +226,7 @@ class TestValidator(unittest.TestCase):
|
||||
with patch('patroni.validator.open', mock_open(read_data='11')):
|
||||
schema(c)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual(['postgresql.data_dir'], parse_output(output))
|
||||
self.assertEqual(['postgresql.data_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
|
||||
|
||||
def test_data_dir_is_empty_string(self, mock_out, mock_err):
|
||||
directories.append(config["postgresql"]["data_dir"])
|
||||
@@ -226,5 +238,5 @@ class TestValidator(unittest.TestCase):
|
||||
c["postgresql"]["bin_dir"] = ""
|
||||
schema(c)
|
||||
output = mock_out.getvalue()
|
||||
self.assertEqual(['kubernetes', 'postgresql.bin_dir',
|
||||
'postgresql.data_dir', 'postgresql.pg_hba'], parse_output(output))
|
||||
self.assertEqual(['kubernetes', 'postgresql.bin_dir', 'postgresql.data_dir',
|
||||
'postgresql.pg_hba', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
|
||||
|
||||
+30
-11
@@ -2,12 +2,13 @@ import select
|
||||
import six
|
||||
import unittest
|
||||
|
||||
from kazoo.client import KazooState
|
||||
from kazoo.client import KazooClient, KazooState
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from kazoo.protocol.states import ZnodeStat
|
||||
from kazoo.protocol.states import KeeperState, ZnodeStat
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.zookeeper import Leader, PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||
from patroni.dcs.zookeeper import Leader, PatroniKazooClient,\
|
||||
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||
|
||||
|
||||
class MockKazooClient(Mock):
|
||||
@@ -17,7 +18,7 @@ class MockKazooClient(Mock):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MockKazooClient, self).__init__()
|
||||
self._session_timeout = 30
|
||||
self._session_timeout = 30000
|
||||
|
||||
@property
|
||||
def client_id(self):
|
||||
@@ -30,7 +31,9 @@ class MockKazooClient(Mock):
|
||||
def get(self, path, watch=None):
|
||||
if not isinstance(path, six.string_types):
|
||||
raise TypeError("Invalid type for 'path' (string expected)")
|
||||
if path == '/no_node':
|
||||
if path == '/broken/status':
|
||||
return (b'{', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
|
||||
elif path in ('/no_node', '/legacy/status'):
|
||||
raise NoNodeError
|
||||
elif '/members/' in path:
|
||||
return (
|
||||
@@ -45,6 +48,8 @@ class MockKazooClient(Mock):
|
||||
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
elif path.endswith('/initialize'):
|
||||
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
elif path.endswith('/status'):
|
||||
return (b'{"optime":500,"slots":{"ls":1234567}}', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
|
||||
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
|
||||
@staticmethod
|
||||
@@ -124,9 +129,19 @@ class TestPatroniSequentialThreadingHandler(unittest.TestCase):
|
||||
self.assertRaises(select.error, self.handler.select)
|
||||
|
||||
|
||||
class TestPatroniKazooClient(unittest.TestCase):
|
||||
|
||||
def test__call(self):
|
||||
c = PatroniKazooClient()
|
||||
with patch.object(KazooClient, '_call', Mock()):
|
||||
self.assertIsNotNone(c._call(None, Mock()))
|
||||
c._state = KeeperState.CONNECTING
|
||||
self.assertFalse(c._call(None, Mock()))
|
||||
|
||||
|
||||
class TestZooKeeper(unittest.TestCase):
|
||||
|
||||
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
|
||||
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10})
|
||||
@@ -147,6 +162,10 @@ class TestZooKeeper(unittest.TestCase):
|
||||
def test__inner_load_cluster(self):
|
||||
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
||||
self.zk._inner_load_cluster()
|
||||
self.zk._base_path = self.zk._base_path = '/broken'
|
||||
self.zk._inner_load_cluster()
|
||||
self.zk._base_path = self.zk._base_path = '/legacy'
|
||||
self.zk._inner_load_cluster()
|
||||
self.zk._base_path = self.zk._base_path = '/no_node'
|
||||
self.zk._inner_load_cluster()
|
||||
|
||||
@@ -156,11 +175,11 @@ class TestZooKeeper(unittest.TestCase):
|
||||
self.assertIsInstance(cluster.leader, Leader)
|
||||
self.zk.touch_member({'foo': 'foo'})
|
||||
self.zk._name = 'bar'
|
||||
self.zk.optime_watcher(None)
|
||||
self.zk.status_watcher(None)
|
||||
with patch.object(ZooKeeper, 'get_node', Mock(side_effect=Exception)):
|
||||
self.zk.get_cluster()
|
||||
cluster = self.zk.get_cluster()
|
||||
self.assertEqual(cluster.last_leader_operation, 500)
|
||||
self.assertEqual(cluster.last_lsn, 500)
|
||||
|
||||
def test_delete_leader(self):
|
||||
self.assertTrue(self.zk.delete_leader())
|
||||
@@ -203,10 +222,10 @@ class TestZooKeeper(unittest.TestCase):
|
||||
self.zk.take_leader()
|
||||
|
||||
def test_update_leader(self):
|
||||
self.assertTrue(self.zk.update_leader(None))
|
||||
self.assertTrue(self.zk.update_leader(12345))
|
||||
|
||||
def test_write_leader_optime(self):
|
||||
self.zk.last_leader_operation = '0'
|
||||
self.zk.last_lsn = '0'
|
||||
self.zk.write_leader_optime('1')
|
||||
with patch.object(MockKazooClient, 'create_async', Mock()):
|
||||
self.zk.write_leader_optime('1')
|
||||
@@ -221,7 +240,7 @@ class TestZooKeeper(unittest.TestCase):
|
||||
def test_watch(self):
|
||||
self.zk.watch(None, 0)
|
||||
self.zk.event.isSet = Mock(return_value=True)
|
||||
self.zk._fetch_optime = False
|
||||
self.zk._fetch_status = False
|
||||
self.zk.watch(None, 0)
|
||||
|
||||
def test__kazoo_connect(self):
|
||||
|
||||
Reference in New Issue
Block a user