Merge pull request #56 from zalando/feature/manual-failover

Feature: manual failover
This commit is contained in:
Alexander Kukushkin
2015-10-09 16:23:45 +02:00
16 changed files with 1043 additions and 353 deletions
+5 -29
View File
@@ -8,7 +8,7 @@ from patroni.api import RestApiServer
from patroni.etcd import Etcd
from patroni.ha import Ha
from patroni.postgresql import Postgresql
from patroni.utils import setup_signal_handlers, sleep, reap_children
from patroni.utils import setup_signal_handlers, reap_children
from patroni.zookeeper import ZooKeeper
logger = logging.getLogger(__name__)
@@ -19,11 +19,11 @@ class Patroni:
def __init__(self, config):
self.nap_time = config['loop_wait']
self.postgresql = Postgresql(config['postgresql'])
self.ha = Ha(self.postgresql, self.get_dcs(self.postgresql.name, config))
self.dcs = self.get_dcs(self.postgresql.name, config)
host, port = config['restapi']['listen'].split(':')
self.api = RestApiServer(self, config['restapi'])
self.ha = Ha(self)
self.next_run = time.time()
self.shutdown_member_ttl = 300
@staticmethod
def get_dcs(name, config):
@@ -33,30 +33,13 @@ class Patroni:
return ZooKeeper(name, config['zookeeper'])
raise Exception('Can not find sutable configuration of distributed configuration store')
def touch_member(self, ttl=None):
connection_string = self.postgresql.connection_string + '?application_name=' + self.api.connection_string
if self.ha.cluster:
for m in self.ha.cluster.members:
# Do not update member TTL when it is far from being expired
if m.name == self.postgresql.name and m.real_ttl() > self.shutdown_member_ttl:
return True
return self.ha.dcs.touch_member(connection_string, ttl)
def initialize(self):
# wait for etcd to be available
while not self.touch_member():
logger.info('waiting on DCS')
sleep(5)
self.postgresql.schedule_load_slots = self.postgresql.is_running() and self.postgresql.use_slots
def schedule_next_run(self):
self.next_run += self.nap_time
current_time = time.time()
nap_time = self.next_run - current_time
if nap_time <= 0:
self.next_run = current_time
elif self.ha.dcs.watch(nap_time):
elif self.dcs.watch(nap_time):
self.next_run = time.time()
def run(self):
@@ -64,12 +47,7 @@ class Patroni:
self.next_run = time.time()
while True:
self.touch_member()
logger.info(self.ha.run_cycle())
try:
self.ha.cluster and self.ha.state_handler.sync_replication_slots(self.ha.cluster)
except:
logger.exception('Exception when changing replication slots')
reap_children()
self.schedule_next_run()
@@ -87,13 +65,11 @@ def main():
config = yaml.load(f)
patroni = Patroni(config)
patroni.initialize()
try:
patroni.run()
except KeyboardInterrupt:
pass
finally:
patroni.api.shutdown()
patroni.touch_member(patroni.shutdown_member_ttl) # schedule member removal
patroni.postgresql.stop()
patroni.ha.dcs.delete_leader()
patroni.dcs.delete_leader()
+74 -18
View File
@@ -44,23 +44,35 @@ class RestApiHandler(BaseHTTPRequestHandler):
def do_GET(self):
"""Default method for processing all GET requests which can not be routed to other methods"""
path = '/master' if self.path == '/' else self.path
response = self.get_postgresql_status()
path = '/master' if self.path == '/' else self.path
status_code = 200 if response['running'] and 'role' in response and response['role'] in path else 503
patroni = self.server.patroni
cluster = patroni.dcs.cluster
if cluster: # dcs available
if cluster.leader and cluster.leader.name == patroni.postgresql.name: # is_leader
status_code = 200 if 'master' in path else 503
elif 'role' not in response:
status_code = 503
elif response['role'] == 'master': # running as master but without leader lock!!!!
status_code = 503
elif response['role'] in path:
status_code = 200
else:
status_code = 503
elif 'role' in response and response['role'] in path:
status_code = 200
elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path:
# exceptional case for master node when the postgres is being restarted via API
status_code = 200
else:
status_code = 503
self.send_response(status_code)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
@check_auth
def do_GET_sampleauth(self):
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(b'Hello!')
def do_GET_patroni(self):
response = self.get_postgresql_status(True)
@@ -69,6 +81,46 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
@check_auth
def do_POST_restart(self):
status_code = 503
data = b'restart failed'
try:
status, msg = self.server.patroni.ha.restart()
status_code = 200 if status else 503
data = msg.encode('utf-8')
except:
logger.exception('Exception during restart')
self.send_response(status_code)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(data)
@check_auth
def do_POST_reinitialize(self):
ha = self.server.patroni.ha
cluster = ha.dcs.get_cluster()
if cluster.is_unlocked():
status_code = 503
data = b'Cluster has no leader, can not reinitialize'
elif cluster.leader.name == ha.state_handler.name:
status_code = 503
data = b'I am the leader, can not reinitialize'
else:
action = ha.schedule_reinitialize()
if action is not None:
status_code = 503
data = (action + ' already in progress').encode('utf-8')
else:
status_code = 200
data = b'reinitialize scheduled'
self.send_response(status_code)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(data)
def parse_request(self):
"""Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class
@@ -90,7 +142,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
def query(self, sql, *params, **kwargs):
if not kwargs.get('retry', False):
return self.server.query(sql, *params)
retry = Retry(delay=2, retry_exceptions=PostgresConnectionException)
retry = Retry(delay=1, retry_exceptions=PostgresConnectionException)
return retry(self.server.query, sql, *params)
def get_postgresql_status(self, retry=False):
@@ -98,15 +150,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
row = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
pg_is_in_recovery(),
CASE WHEN pg_is_in_recovery()
THEN null
ELSE pg_current_xlog_location() END,
pg_last_xlog_receive_location(),
pg_last_xlog_replay_location(),
THEN 0
ELSE pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint
END,
pg_xlog_location_diff(pg_last_xlog_receive_location(), '0/0')::bigint,
pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')::bigint,
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""", retry=retry)[0]
return {
'running': True,
'state': self.server.patroni.postgresql.state,
'postmaster_start_time': row[0],
'role': 'slave' if row[1] else 'master',
'role': 'replica' if row[1] else 'master',
'xlog': ({
'received_location': row[3],
'replayed_location': row[4],
@@ -115,8 +168,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
})
}
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
logger.exception('get_postgresql_status')
return {'running': self.server.patroni.postgresql.is_running()}
state = self.server.patroni.postgresql.state
if state in ['stopped', 'starting', 'stopping', 'restarting', 'running']:
logger.exception('get_postgresql_status')
state = 'unknown' if state == 'running' else state
return {'state': state}
class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
+55
View File
@@ -0,0 +1,55 @@
import logging
from threading import Lock, Thread
logger = logging.getLogger(__name__)
class AsyncExecutor:
def __init__(self):
Lock.__init__(self)
self._busy = False
self._thread_lock = Lock()
self._scheduled_action = None
self._scheduled_action_lock = Lock()
@property
def busy(self):
return self._busy
def schedule(self, action, immediately=False):
with self._scheduled_action_lock:
if self._scheduled_action is not None:
return self._scheduled_action
self._scheduled_action = action
self._busy = immediately
return None
@property
def scheduled_action(self):
with self._scheduled_action_lock:
return self._scheduled_action
def reset_scheduled_action(self):
with self._scheduled_action_lock:
self._scheduled_action = None
def run(self, func, args=()):
try:
return func(*args) if args else func()
except:
logger.exception('Exception during execution of long running task %s', self.scheduled_action)
finally:
with self:
self._busy = False
self.reset_scheduled_action()
def run_async(self, func, args=()):
self._busy = True
Thread(target=self.run, args=(func, args)).start()
def __enter__(self):
self._thread_lock.acquire()
def __exit__(self, type, value, traceback):
self._thread_lock.release()
+97 -22
View File
@@ -1,9 +1,10 @@
import abc
import json
from collections import namedtuple
from patroni.exceptions import DCSError
from patroni.utils import calculate_ttl, sleep
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
from threading import Event, Lock
def parse_connection_string(value):
@@ -23,28 +24,52 @@ def parse_connection_string(value):
return conn_url, api_url
class Member(namedtuple('Member', 'index,name,conn_url,api_url,expiration,ttl')):
class Member(namedtuple('Member', 'index,name,session,data')):
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
Consists of the following fields:
:param index: modification index of a given member key in a Configuration Store
:param name: name of PostgreSQL cluster member
:param conn_url: connection string containing host, user and password which could be used to access this member.
:param api_url: REST API url of patroni instance
:param expiration: expiration time of given member key
:param ttl: ttl of given member key in seconds"""
:param session: either session id or just ttl in seconds
:param data: arbitrary data i.e. conn_url, api_url, xlog location, state, role, tags, etc...
def real_ttl(self):
return calculate_ttl(self.expiration) or -1
There are two mandatory keys in a data:
conn_url: connection string containing host, user and password which could be used to access this member.
api_url: REST API url of patroni instance"""
@staticmethod
def from_node(index, name, session, data):
"""
>>> Member.from_node(-1, '', '', '{"conn_url": "postgres://foo@bar/postgres"}') is not None
True
>>> Member.from_node(-1, '', '', '{')
Member(index=-1, name='', session='', data={})
"""
if data.startswith('postgres'):
conn_url, api_url = parse_connection_string(data)
data = {'conn_url': conn_url, 'api_url': api_url}
else:
try:
data = json.loads(data)
except:
data = {}
return Member(index, name, session, data)
@property
def conn_url(self):
return self.data.get('conn_url', None)
@property
def api_url(self):
return self.data.get('api_url', None)
class Leader(namedtuple('Leader', 'index,expiration,ttl,member')):
class Leader(namedtuple('Leader', 'index,session,member')):
"""Immutable object (namedtuple) which represents leader key.
Consists of the following fields:
:param index: modification index of a leader key in a Configuration Store
:param expiration: expiration time of the leader key
:param ttl: ttl of the leader key
:param session: either session id or just ttl in seconds
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
@property
@@ -56,7 +81,15 @@ class Leader(namedtuple('Leader', 'index,expiration,ttl,member')):
return self.member.conn_url
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members')):
class Failover(namedtuple('Failover', 'index,leader,member')):
@staticmethod
def from_node(index, value):
t = [a.strip() for a in value.split(':')] + ['']
return Failover(index, t[0], t[1]) if t[0] or t[1] else None
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members,failover')):
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
Consists of the following fields:
@@ -64,7 +97,8 @@ class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,mem
: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 members: list of Member object, all PostgreSQL cluster members including leader"""
:param members: list of Member object, all PostgreSQL cluster members including leader
:param failover: reference to `Failover` object"""
def is_unlocked(self):
return not (self.leader and self.leader.name)
@@ -76,6 +110,7 @@ class AbstractDCS:
_INITIALIZE = 'initialize'
_LEADER = 'leader'
_FAILOVER = 'failover'
_MEMBERS = 'members/'
_OPTIME = 'optime'
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
@@ -90,6 +125,10 @@ class AbstractDCS:
self._scope = config['scope']
self._base_path = '/service/' + self._scope
self._cluster = None
self._cluster_thread_lock = Lock()
self.event = Event()
def client_path(self, path):
return '/'.join([self._base_path, path.lstrip('/')])
@@ -109,25 +148,54 @@ class AbstractDCS:
def leader_path(self):
return self.client_path(self._LEADER)
@property
def failover_path(self):
return self.client_path(self._FAILOVER)
@property
def leader_optime_path(self):
return self.client_path(self._LEADER_OPTIME)
@abc.abstractmethod
def _load_cluster(self):
"""Internally this method should build `Cluster` object which
represents current state and topology of the cluster in DCS.
this method supposed to be called only by `get_cluster` method.
raise `~DCSError` in case of communication or other problems with DCS.
If the current node was running as a master and exception raised,
instance would be demoted."""
def get_cluster(self):
""":returns: `Cluster` object which represent current state and topology of the cluster
raise `~DCSError` in case of communication or other problems with DCS. If current instance was
running as a master and exception raised instance would be demoted."""
with self._cluster_thread_lock:
try:
self._load_cluster()
except:
self._cluster = None
raise
return self._cluster
@property
def cluster(self):
with self._cluster_thread_lock:
return self._cluster
def reset_cluster(self):
with self._cluster_thread_lock:
self._cluster = None
@abc.abstractmethod
def update_leader(self, state_handler):
"""Update leader key (or session) ttl and `/optime/leader` key in DCS.
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"""
@abc.abstractmethod
def update_leader(self):
"""Update leader key (or session) ttl
:param state_handler: reference to `Postgresql` object
:returns: `!True` if leader key (or session) has been updated successfully.
If not, `!False` must be returned and current instance would be demoted.
If you failed to update `/optime/leader` this error is not critical and you can return `!True`
You have to use CAS (Compare And Swap) operation in order to update leader key,
for example for etcd `prevValue` parameter must be used."""
@@ -140,6 +208,13 @@ class AbstractDCS:
Key must be created atomically. In case if key already exists it should not be
overwritten and `!False` must be returned"""
@abc.abstractmethod
def set_failover_value(self, value, index=None):
"""Create or update `/failover` key"""
def manual_failover(self, leader, member, index=None):
return self.set_failover_value(leader + (':' + member if member else ''), index)
def current_leader(self):
try:
cluster = self.get_cluster()
@@ -188,5 +263,5 @@ class AbstractDCS:
:param timeout: timeout in seconds
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
sleep(timeout)
return False
self.event.wait(timeout)
return self.event.isSet()
+32 -25
View File
@@ -10,7 +10,8 @@ import urllib3
from dns.exception import DNSException
from dns import resolver
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import Retry, RetryFailedError, sleep
from requests.exceptions import RequestException
@@ -80,7 +81,7 @@ class Client(etcd.Client):
for host, port in self.get_srv_record(discovery_srv):
url = '{}://{}:{}/members'.format(self._protocol, host, port)
try:
response = requests.get(url)
response = requests.get(url, timeout=5)
if response.ok:
for member in response.json():
ret.extend(member['clientURLs'])
@@ -146,14 +147,12 @@ class Etcd(AbstractDCS):
def __init__(self, name, config):
super(Etcd, self).__init__(name, config)
self.ttl = config['ttl']
self.member_ttl = config.get('member_ttl', 3600)
self._retry = Retry(deadline=10, max_delay=1, max_tries=-1,
retry_exceptions=(etcd.EtcdConnectionFailed,
etcd.EtcdLeaderElectionInProgress,
etcd.EtcdWatcherCleared,
etcd.EtcdEventIndexCleared))
self.client = self.get_etcd_client(config)
self.cluster = None
def retry(self, *args, **kwargs):
return self._retry.copy()(*args, **kwargs)
@@ -170,10 +169,9 @@ class Etcd(AbstractDCS):
@staticmethod
def member(node):
conn_url, api_url = parse_connection_string(node.value)
return Member(node.modifiedIndex, os.path.basename(node.key), conn_url, api_url, node.expiration, node.ttl)
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
def get_cluster(self):
def _load_cluster(self):
try:
result = self.retry(self.client.read, self.client_path(''), recursive=True)
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
@@ -191,22 +189,25 @@ class Etcd(AbstractDCS):
# get leader
leader = nodes.get(self._LEADER, None)
if leader:
member = Member(-1, leader.value, None, None, None, None)
member = Member(-1, leader.value, None, {})
member = ([m for m in members if m.name == leader.value] or [member])[0]
leader = Leader(leader.modifiedIndex, leader.expiration, leader.ttl, member)
leader = Leader(leader.modifiedIndex, leader.ttl, member)
self.cluster = Cluster(initialize, leader, last_leader_operation, members)
# failover key
failover = nodes.get(self._FAILOVER, None)
if failover:
failover = Failover.from_node(failover.modifiedIndex, failover.value)
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
except etcd.EtcdKeyNotFound:
self.cluster = Cluster(False, None, None, [])
self._cluster = Cluster(False, None, None, [], None)
except:
self.cluster = None
logger.exception('get_cluster')
raise EtcdError('Etcd is not responding properly')
return self.cluster
@catch_etcd_errors
def touch_member(self, connection_string, ttl=None):
return self.retry(self.client.set, self.member_path, connection_string, ttl or self.member_ttl)
return self.retry(self.client.set, self.member_path, connection_string, ttl or self.ttl)
@catch_etcd_errors
def take_leader(self):
@@ -222,18 +223,20 @@ class Etcd(AbstractDCS):
return False
@catch_etcd_errors
def write_leader_optime(self, state_handler):
return self.client.set(self.leader_optime_path, state_handler.last_operation())
def set_failover_value(self, value, index=None):
return self.client.write(self.failover_path, value, prevIndex=index or 0)
@catch_etcd_errors
def update_leader(self, state_handler):
ret = self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
ret and self.write_leader_optime(state_handler)
return ret
def write_leader_optime(self, last_operation):
return self.client.set(self.leader_optime_path, last_operation)
@catch_etcd_errors
def update_leader(self):
return self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
@catch_etcd_errors
def initialize(self):
return self.client.write(self.initialize_path, self._name, prevExist=False)
return self.retry(self.client.write, self.initialize_path, self._name, prevExist=False)
@catch_etcd_errors
def delete_leader(self):
@@ -241,13 +244,14 @@ class Etcd(AbstractDCS):
@catch_etcd_errors
def cancel_initialization(self):
return self.client.delete(self.initialize_path, prevValue=self._name)
return self.retry(self.client.delete, self.initialize_path, prevValue=self._name)
def watch(self, timeout):
cluster = self.cluster
# watch on leader key changes if it is defined and current node is not lock owner
if self.cluster and self.cluster.leader and self.cluster.leader.name != self._name:
if cluster and cluster.leader and cluster.leader.name != self._name:
end_time = time.time() + timeout
index = self.cluster.leader.index
index = cluster.leader.index
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try:
@@ -263,4 +267,7 @@ class Etcd(AbstractDCS):
timeout = end_time - time.time()
return timeout > 0 and super(Etcd, self).watch(timeout)
try:
return super(Etcd, self).watch(timeout)
finally:
self.event.clear()
+279 -34
View File
@@ -1,26 +1,30 @@
import json
import logging
import psycopg2
import requests
from patroni.async_executor import AsyncExecutor
from patroni.exceptions import DCSError, PostgresConnectionException
from multiprocessing.pool import ThreadPool
logger = logging.getLogger(__name__)
class Ha:
def __init__(self, state_handler, etcd):
self.state_handler = state_handler
self.dcs = etcd
def __init__(self, patroni):
self.patroni = patroni
self.state_handler = patroni.postgresql
self.dcs = patroni.dcs
self.cluster = None
self.old_cluster = None
self._async_executor = AsyncExecutor()
def load_cluster_from_dcs(self):
cluster = self.dcs.get_cluster()
# We want to keep the state of cluster when it was healhy
if cluster.is_unlocked() and self.cluster and not self.cluster.is_unlocked():
self.old_cluster = self.cluster
if not self.old_cluster:
if not cluster.is_unlocked() or not self.old_cluster:
self.old_cluster = cluster
self.cluster = cluster
@@ -28,22 +32,46 @@ class Ha:
return self.dcs.attempt_to_acquire_leader()
def update_lock(self):
return self.dcs.update_leader(self.state_handler)
ret = self.dcs.update_leader()
if ret:
try:
self.dcs.write_leader_optime(self.state_handler.last_operation())
except:
pass
return ret
def has_lock(self):
lock_owner = self.cluster.leader and self.cluster.leader.name
logger.info('Lock owner: %s; I am %s', lock_owner, self.state_handler.name)
return lock_owner == self.state_handler.name
def touch_member(self):
data = {
'conn_url': self.state_handler.connection_string,
'api_url': self.patroni.api.connection_string,
'state': self.state_handler.state,
'role': self.state_handler.role
}
if data['state'] in ['running', 'restarting', 'starting']:
try:
data['xlog_location'] = self.state_handler.xlog_position()
except:
pass
self.dcs.touch_member(json.dumps(data, separators=(',', ':')))
def copy_backup_from_leader(self, leader):
if self.state_handler.bootstrap(leader):
logger.info('bootstrapped from leader')
else:
self.state_handler.stop('immediate')
self.state_handler.remove_data_directory()
logger.error('failed to bootstrap from leader')
def bootstrap(self):
if not self.cluster.is_unlocked(): # cluster already has leader
logger.info('trying to bootstrap from leader', )
if self.state_handler.bootstrap(self.cluster.leader):
return 'bootstrapped from leader'
else:
self.state_handler.stop('immediate')
self.state_handler.remove_data_directory()
return 'failed to bootstrap from leader'
self._async_executor.schedule('bootstrap from leader')
self._async_executor.run_async(self.copy_backup_from_leader, args=(self.cluster.leader, ))
return 'trying to bootstrap from leader'
elif not self.cluster.initialize: # no initialize key
if self.dcs.initialize(): # race for initialization
try:
@@ -63,20 +91,27 @@ class Ha:
return 'waiting for leader to bootstrap'
def recover(self):
if self.state_handler.is_healthy():
return False
has_lock = self.has_lock()
self.state_handler.write_recovery_conf(None if has_lock else self.cluster.leader)
self.state_handler.start()
if has_lock:
logger.info('started as readonly because i had the session lock')
self.load_cluster_from_dcs()
return True
if not self.state_handler.start():
if not has_lock:
return 'failed to start postgres'
self.dcs.delete_leader()
self.dcs.reset_cluster()
return 'removed leader key after trying and failing to start postgres'
if not has_lock:
return 'started as a secondary'
logger.info('started as readonly because i had the session lock')
self.load_cluster_from_dcs()
def follow_the_leader(self, demote_reason, follow_reason, refresh=True):
refresh and self.load_cluster_from_dcs()
ret = demote_reason if self.state_handler.is_leader() else follow_reason
self.state_handler.follow_the_leader(self.cluster.leader)
leader = self.cluster.leader
leader = None if (leader and leader.name) == self.state_handler.name else leader
if not self.state_handler.check_recovery_conf(leader):
self._async_executor.schedule('changing primary_conninfo and restarting')
self._async_executor.run_async(self.state_handler.follow_the_leader, (leader, ))
return ret
def enforce_master_role(self, message, promote_message):
@@ -86,9 +121,145 @@ class Ha:
self.state_handler.promote()
return promote_message
@staticmethod
def fetch_node_status(member):
"""This function perform http get request on member.api_url and fetches its status
:returns: tuple(`member`, reachable, in_recovery, xlog_location)
reachable - `!False` if the node is not reachable or is not responding with correct JSON
in_recovery - `!True` if pg_is_in_recovery() == true
xlog_location - value of `replayed_location` or `location` from JSON, dependin on its role."""
try:
response = requests.get(member.api_url, timeout=2, verify=False)
logger.info('Got response from %s %s: %s', member.name, member.api_url, response.content)
json = response.json()
is_master = json['role'] == 'master'
xlog_location = json['xlog']['location' if is_master else 'replayed_location']
return (member, True, not is_master, xlog_location)
except:
logging.exception('request failed: GET %s', member.api_url)
return (member, False, None, 0)
def fetch_nodes_statuses(self, members):
pool = ThreadPool(len(members))
results = pool.map(self.fetch_node_status, members) # Run API calls on members in parallel
pool.close()
pool.join()
return results
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."""
if self.state_handler.is_leader():
return True
if check_replication_lag and not self.state_handler.check_replication_lag(self.cluster.last_leader_operation):
return False # Too far behind last reported xlog location on master
# Prepare list of nodes to run check against
members = [m for m in members if m.name != self.state_handler.name and m.api_url]
if members:
my_xlog_location = self.state_handler.xlog_position()
for member, reachable, in_recovery, xlog_location in self.fetch_nodes_statuses(members):
if reachable: # If the node is unreachable it's not healhy
if not in_recovery:
logger.warning('Master (%s) is still alive', member.name)
return False
if my_xlog_location < xlog_location:
return False
return True
def is_failover_possible(self, members):
ret = False
members = [m for m in members if m.name != self.state_handler.name and m.api_url]
if members:
for member, reachable, in_recovery, xlog_location in self.fetch_nodes_statuses(members):
if reachable:
ret = True # TODO: check xlog_location
else:
logger.info('Member %s is not reachable', member.name)
else:
logger.warning('manual failover: members list is empty')
return ret
def manual_failover_process_no_leader(self):
failover = self.cluster.failover
if failover.member: # manual failover to specific member
if failover.member == self.state_handler.name: # manual failover to me
return True
# find specific node and check that it is healthy
members = [m for m in self.cluster.members if m.name == failover.member]
if members:
member, reachable, in_recovery, xlog_location = self.fetch_node_status(members[0])
if reachable: # node is healthy
logger.info('manual failover: to %s, i am %s', member.name, self.state_handler.name)
return False
# we wanted to failover to specific member but it is not healthy
logger.warning('manual failover: member %s is unhealthy', member.name)
# at this point we should consider all members as a candidates for failover
# i.e. we assume that failover.member is None
# try to pick some other members to failover and check that they are healthy
if failover.leader:
if self.state_handler.name == failover.leader: # I was the leader
# exclude me and desired member which is unhealthy (failover.member can be None)
members = [m for m in self.cluster.members if m.name != failover.member]
if self.is_failover_possible(members): # check that there are healthy members
return False
else: # I was the leader and it looks like currently I am the only healthy member
return True
# at this point we assume that our node is a candidate for a failover among all nodes except former leader
# exclude former leader from the list (failover.leader can be None)
members = [m for m in self.cluster.members if m.name != failover.leader]
return self._is_healthiest_node(members, check_replication_lag=False)
def is_healthiest_node(self):
if self.cluster.failover:
return self.manual_failover_process_no_leader()
# run usual health check
members = {m.name: m for m in self.cluster.members + self.old_cluster.members}
return self._is_healthiest_node(members.values())
def demote(self, delete_leader=True):
if delete_leader:
self.state_handler.stop()
self.dcs.delete_leader()
self.dcs.reset_cluster()
self.state_handler.follow_the_leader(None)
def process_manual_failover_from_leader(self):
failover = self.cluster.failover
if not failover.leader or failover.leader == self.state_handler.name:
if not failover.member or failover.member != self.state_handler.name:
members = [m for m in self.cluster.members if not failover.member or m.name == failover.member]
if self.is_failover_possible(members): # check that there are healthy members
self._async_executor.schedule('manual failover: demote')
self._async_executor.run_async(self.demote)
return 'manual failover: demoting myself'
else:
logger.warning('manual failover: no healthy members found, failover is not possible')
else:
logger.warning('manual failover: I am already the leader, no need to failover')
else:
logger.warning('manual failover: leader name does not match: %s != %s',
self.cluster.failover.leader, self.state_handler.name)
logger.info('Trying to clean up failover key')
self.dcs.manual_failover('', '', self.cluster.failover.index)
def process_unhealthy_cluster(self):
if self.state_handler.is_healthiest_node(self.old_cluster):
if self.is_healthiest_node():
if self.acquire_lock():
if self.cluster.failover:
logger.info('Cleanning up failover key after acquiring leader lock...')
self.dcs.manual_failover('', '')
return self.enforce_master_role('acquired session lock as a leader',
'promoted self to leader by acquiring session lock')
else:
@@ -100,6 +271,11 @@ class Ha:
def process_healthy_cluster(self):
if self.has_lock():
if self.cluster.failover:
msg = self.process_manual_failover_from_leader()
if msg is not None:
return msg
if self.update_lock():
return self.enforce_master_role('no action. i am the leader with the lock',
'promoted self to leader because i had the session lock')
@@ -112,14 +288,75 @@ class Ha:
return self.follow_the_leader('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', False)
def run_cycle(self):
def schedule(self, action):
with self._async_executor:
return self._async_executor.schedule(action)
def restart_scheduled(self):
return self._async_executor.scheduled_action == 'restart'
def schedule_reinitialize(self):
return self.schedule('reinitialize')
def reinitialize_scheduled(self):
return self._async_executor.scheduled_action == 'reinitialize'
def restart(self):
with self._async_executor:
prev = self._async_executor.schedule('restart', True)
if prev is not None:
return (False, prev + ' already in progress')
if self._async_executor.run(self.state_handler.restart):
return (True, 'restarted successfully')
else:
return (False, 'restart failed')
def reinitialize(self, cluster):
self.state_handler.stop('immediate')
self.state_handler.remove_data_directory()
self.copy_backup_from_leader(cluster.leader)
def process_scheduled_action(self):
if self.reinitialize_scheduled():
if self.cluster.is_unlocked():
logger.error('Cluster has no leader, can not reinitialize')
self._async_executor.reset_scheduled_action()
elif self.has_lock():
logger.error('I am the leader, can not reinitialize')
self._async_executor.reset_scheduled_action()
else:
self._async_executor.run_async(self.reinitialize, args=(self.cluster, ))
return 'reinitialize started'
def handle_long_action_in_progress(self):
if self.has_lock():
if self.update_lock():
return 'updated leader lock during ' + self._async_executor.scheduled_action
else:
return 'failed to update leader lock during ' + self._async_executor.scheduled_action
elif self.cluster.is_unlocked():
return 'not healthy enough for leader race'
else:
return self._async_executor.scheduled_action + ' in progress'
def _run_cycle(self):
try:
self.load_cluster_from_dcs()
self.touch_member()
# cluster has leader key but not initialize key
if not self.cluster.is_unlocked() and not self.cluster.initialize:
self.dcs.initialize() # fix it
if self._async_executor.busy:
return self.handle_long_action_in_progress()
# currently it can trigger only reinitialize
msg = self.process_scheduled_action()
if msg is not None:
return msg
# is data directory empty?
if self.state_handler.data_directory_empty():
return self.bootstrap() # new node
@@ -128,18 +365,26 @@ class Ha:
self.dcs.initialize()
# try to start dead postgres
if self.recover() and not self.has_lock():
# no lock, do not try to promote immediately
return 'started as a secondary'
if not self.state_handler.is_healthy():
msg = self.recover()
if msg is not None:
return msg
if self.cluster.is_unlocked():
return self.process_unhealthy_cluster()
else:
return self.process_healthy_cluster()
try:
if self.cluster.is_unlocked():
return self.process_unhealthy_cluster()
else:
return self.process_healthy_cluster()
finally:
self.state_handler.sync_replication_slots(self.cluster)
except DCSError:
logger.error('Error communicating with DCS')
if self.state_handler.is_leader():
self.state_handler.demote(None)
if self.state_handler.is_running() and self.state_handler.is_leader():
self.demote(delete_leader=False)
return 'demoted self because DCS is not accessible and i was a leader'
except (psycopg2.Error, PostgresConnectionException):
logger.exception('Error communicating with Postgresql. Will try again')
logger.exception('Error communicating with Postgresql. Will try again later')
def run_cycle(self):
with self._async_executor:
return self._run_cycle()
+99 -66
View File
@@ -9,6 +9,7 @@ import time
from patroni.exceptions import PostgresConnectionException, PostgresException
from patroni.utils import Retry, RetryFailedError
from six.moves.urllib_parse import urlparse
from threading import Lock
logger = logging.getLogger(__name__)
@@ -56,7 +57,6 @@ class Postgresql:
self.postmaster_pid = os.path.join(self.data_dir, 'postmaster.pid')
self.trigger_file = config.get('recovery_conf', {}).get('trigger_file', None) or 'promote'
self.trigger_file = os.path.abspath(os.path.join(self.data_dir, self.trigger_file))
self._role = 'replica'
self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir]
@@ -67,8 +67,17 @@ class Postgresql:
self._connection = None
self._cursor_holder = None
self.members = [] # list of already existing replication slots
self.retry = Retry(max_tries=-1, deadline=10, max_delay=1, retry_exceptions=PostgresConnectionException)
self.replication_slots = [] # list of already existing replication slots
self.retry = Retry(max_tries=-1, deadline=5, max_delay=1, retry_exceptions=PostgresConnectionException)
self._state = 'stopped'
self._state_lock = Lock()
self._role = 'replica'
self._role_lock = Lock()
if self.is_running():
self._state = 'running'
self._role = 'master' if self.is_leader() else 'replica'
def get_local_address(self):
listen_addresses = self.listen_addresses.split(',')
@@ -101,6 +110,8 @@ class Postgresql:
except psycopg2.Error as e:
if cursor and cursor.connection.closed == 0:
raise e
if self.state == 'restarting':
raise RetryFailedError('cluster is being restarted')
raise PostgresConnectionException('connection problems')
def query(self, sql, *params):
@@ -113,8 +124,12 @@ class Postgresql:
return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == []
def initialize(self):
self.set_state('initalizing new cluster')
ret = subprocess.call(self._pg_ctl + ['initdb', '-o', '--encoding=UTF8']) == 0
ret and self.write_pg_hba()
if ret:
self.write_pg_hba()
else:
self.set_state('initdb failed')
return ret
def delete_trigger_file(self):
@@ -137,6 +152,7 @@ class Postgresql:
return "host={host} port={port} user={user}".format(**conn)
def create_replica(self, master_connection, env):
self.set_state('building replica from {host}:{port}'.format(**master_connection))
connstring = self.build_connstring(master_connection)
cmd = self.config['restore']
try:
@@ -144,7 +160,9 @@ class Postgresql:
self.delete_trigger_file()
except:
logger.exception('Error when creating replica')
return 1
ret = 1
if ret != 0:
self.set_state('failed to build replica from {host}:{port}'.format(**master_connection))
return ret
def is_leader(self):
@@ -167,40 +185,76 @@ class Postgresql:
@property
def role(self):
return self._role
with self._role_lock:
return self._role
def set_role(self, value):
with self._role_lock:
self._role = value
@property
def state(self):
with self._state_lock:
return self._state
def set_state(self, value):
with self._state_lock:
self._state = value
def start(self, block_callbacks=False):
if self.is_running():
self._role = 'master' if self.is_leader() else 'replica'
self.schedule_load_slots = self.use_slots
logger.error('Cannot start PostgreSQL because one is already running.')
return False
return True
self._role = 'replica' if os.path.exists(self.recovery_conf) else 'master'
self.set_role('replica' if os.path.exists(self.recovery_conf) else 'master')
if os.path.exists(self.postmaster_pid):
os.remove(self.postmaster_pid)
logger.info('Removed %s', self.postmaster_pid)
if not block_callbacks:
self.set_state('starting')
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0
self.set_state('running' if ret else 'start failed')
self.schedule_load_slots = ret and self.use_slots
self.save_configuration_files()
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
ret and not block_callbacks and ret and self.call_nowait(ACTION_ON_START)
ret and not block_callbacks and self.call_nowait(ACTION_ON_START)
return ret
def checkpoint(self):
try:
r = parseurl('postgres://{}/postgres'.format(self.local_address))
r['options'] = '-c statement_timeout=0'
with psycopg2.connect(**r) as conn:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute('CHECKPOINT')
except:
logging.exception('Exception during CHECKPOINT')
def stop(self, mode='fast', block_callbacks=False):
if not self.is_running():
if not block_callbacks:
self.set_state('stopped')
return True
if block_callbacks:
try:
self.query('SET statement_timeout TO 0')
self.query('CHECKPOINT')
except:
logging.exception('Exception diring CHECKPOINT')
self.checkpoint()
else:
self.set_state('stopping')
ret = subprocess.call(self._pg_ctl + ['stop', '-m', mode]) == 0
# block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones
ret and not block_callbacks and self.call_nowait(ACTION_ON_STOP)
if not ret:
self.set_state('stop failed')
elif not block_callbacks:
self.set_state('stopped')
self.call_nowait(ACTION_ON_STOP)
return ret
def reload(self):
@@ -209,8 +263,12 @@ class Postgresql:
return ret
def restart(self):
self.set_state('restarting')
ret = self.stop(block_callbacks=True) and self.start(block_callbacks=True)
ret and self.call_nowait(ACTION_ON_RESTART)
if ret:
self.call_nowait(ACTION_ON_RESTART)
else:
self.set_state('restart failed ({})'.format(self.state))
return ret
def server_options(self):
@@ -225,36 +283,8 @@ class Postgresql:
return False
return True
def is_healthiest_node(self, cluster):
if self.is_leader():
return True
if cluster.last_leader_operation - self.xlog_position() > self.config.get('maximum_lag_on_failover', 0):
return False
for member in cluster.members:
if member.name == self.name:
continue
try:
r = parseurl(member.conn_url)
member_conn = psycopg2.connect(**r)
member_conn.autocommit = True
member_cursor = member_conn.cursor()
member_cursor.execute(
"SELECT pg_is_in_recovery(), %s - pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')",
(self.xlog_position(),))
row = member_cursor.fetchone()
member_cursor.close()
member_conn.close()
logger.error([self.name, member.name, row])
if not row[0]:
logger.warning('Master (%s) is still alive', member.name)
return False
if row[1] < 0:
return False
except psycopg2.Error:
continue
return True
def check_replication_lag(self, last_leader_operation):
return last_leader_operation - self.xlog_position() <= self.config.get('maximum_lag_on_failover', 0)
def write_pg_hba(self):
with open(os.path.join(self.data_dir, 'pg_hba.conf'), 'a') as f:
@@ -324,12 +354,12 @@ recovery_target_timeline = 'latest'
return True
ret = subprocess.call(self._pg_ctl + ['promote']) == 0
if ret:
self._role = 'master'
self.set_role('master')
self.call_nowait(ACTION_ON_ROLE_CHANGE)
return ret
def demote(self, leader):
self.follow_the_leader(leader)
def demote(self):
self.follow_the_leader(None)
def create_replication_user(self):
self.query('CREATE USER "{}" WITH REPLICATION ENCRYPTED PASSWORD %s'.format(
@@ -351,31 +381,34 @@ recovery_target_timeline = 'latest'
return self.query("""SELECT pg_xlog_location_diff(CASE WHEN pg_is_in_recovery()
THEN pg_last_xlog_replay_location()
ELSE pg_current_xlog_location()
END, '0/0')""").fetchone()[0]
END, '0/0')::bigint""").fetchone()[0]
def load_replication_slots(self):
if self.use_slots and self.schedule_load_slots:
cursor = self.query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
self.members = [r[0] for r in cursor]
self.replication_slots = [r[0] for r in cursor]
self.schedule_load_slots = False
def sync_replication_slots(self, cluster):
if self.use_slots:
self.load_replication_slots()
members = [m.name for m in cluster.members if m.name != self.name] if self.role == 'master' else []
# drop unused slots
for slot in set(self.members) - set(members):
self.query("""SELECT pg_drop_replication_slot(%s)
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
try:
self.load_replication_slots()
slots = [m.name for m in cluster.members if m.name != self.name] if self.role == 'master' else []
# drop unused slots
for slot in set(self.replication_slots) - set(slots):
self.query("""SELECT pg_drop_replication_slot(%s)
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
# create new slots
for slot in set(members) - set(self.members):
self.query("""SELECT pg_create_physical_replication_slot(%s)
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
# create new slots
for slot in set(slots) - set(self.replication_slots):
self.query("""SELECT pg_create_physical_replication_slot(%s)
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
WHERE slot_name = %s)""", slot, slot)
self.members = members
self.replication_slots = slots
except:
logger.exception('Exception when changing replication slots')
def last_operation(self):
return str(self.xlog_position())
+2
View File
@@ -36,6 +36,8 @@ def calculate_ttl(expiration):
"""
>>> calculate_ttl(None)
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z')
>>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0
True
"""
if not expiration:
return None
+68 -33
View File
@@ -5,7 +5,8 @@ import time
from kazoo.client import KazooClient, KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
from patroni.exceptions import DCSError
from patroni.utils import sleep
from requests.exceptions import RequestException
@@ -90,9 +91,8 @@ class ZooKeeper(AbstractDCS):
'max_tries': -1},
connection_retry={'max_delay': 1, 'max_tries': -1})
self.client.add_listener(self.session_listener)
self.cluster_event = self.client.handler.event_object()
self.cluster = None
self._my_member_data = None
self.fetch_cluster = True
self.last_leader_operation = 0
@@ -104,7 +104,7 @@ class ZooKeeper(AbstractDCS):
def cluster_watcher(self, event):
self.fetch_cluster = True
self.cluster_event.set()
self.event.set()
def get_node(self, key, watch=None):
try:
@@ -115,8 +115,7 @@ class ZooKeeper(AbstractDCS):
@staticmethod
def member(name, value, znode):
conn_url, api_url = parse_connection_string(value)
return Member(znode.version, name, conn_url, api_url, None, None)
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
def get_children(self, key, watch=None):
try:
@@ -133,8 +132,11 @@ class ZooKeeper(AbstractDCS):
return members
def _inner_load_cluster(self):
self.cluster_event.clear()
nodes = set(self.get_children(self.client_path('')))
self.fetch_cluster = False
self.event.clear()
nodes = set(self.get_children(self.client_path(''), self.cluster_watcher))
if not nodes:
self.fetch_cluster = True
# get initialize flag
initialize = self._INITIALIZE in nodes
@@ -143,7 +145,7 @@ class ZooKeeper(AbstractDCS):
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
# get leader
leader = self.get_node(self.leader_path, self.cluster_watcher) if self._LEADER in nodes else None
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
if leader:
client_id = self.client.client_id
if leader[0] == self._name and client_id is not None and client_id[0] != leader[1].ephemeralOwner:
@@ -152,17 +154,22 @@ class ZooKeeper(AbstractDCS):
leader = None
if leader:
member = Member(-1, leader[0], None, None, None, None)
member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].version, None, None, member)
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self.fetch_cluster = member.index == -1
# get last leader operation
self.last_leader_operation = self.get_node(self.leader_optime_path) if self.fetch_cluster else None
self.last_leader_operation = 0 if self.last_leader_operation is None else int(self.last_leader_operation[0])
self.cluster = Cluster(initialize, leader, self.last_leader_operation, members)
# failover key
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
if failover:
failover = Failover.from_node(failover[1].version, failover[0])
def get_cluster(self):
# get last leader operation
optime = self.get_node(self.leader_optime_path) if self._OPTIME in nodes and self.fetch_cluster else None
self.last_leader_operation = 0 if optime is None else int(optime[0])
self._cluster = Cluster(initialize, leader, self.last_leader_operation, members, failover)
def _load_cluster(self):
if self.exhibitor and self.exhibitor.poll():
self.client.set_hosts(self.exhibitor.zookeeper_hosts)
@@ -170,11 +177,9 @@ class ZooKeeper(AbstractDCS):
try:
self.client.retry(self._inner_load_cluster)
except:
self.cluster = None
logger.exception('get_cluster')
self.session_listener(KazooState.LOST)
raise ZooKeeperError('ZooKeeper in not responding properly')
return self.cluster
def _create(self, path, value, **kwargs):
try:
@@ -188,31 +193,60 @@ class ZooKeeper(AbstractDCS):
ret or logger.info('Could not take out TTL lock')
return ret
def set_failover_value(self, value, index=None):
try:
self.client.retry(self.client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
return True
except NoNodeError:
return value == '' or (not index and self._create(self.failover_path, value.encode('utf-8')))
except:
logging.exception('set_failover_value')
return False
def initialize(self):
return self._create(self.initialize_path, self._name, makepath=True)
def touch_member(self, connection_string, ttl=None):
if self.cluster and any(m.name == self._name for m in self.cluster.members):
return True
def touch_member(self, data, ttl=None):
cluster = self.cluster
me = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
path = self.member_path
connection_string = connection_string.encode('utf-8')
data = data.encode('utf-8')
create = not me
if me and self.client.client_id is not None and me.session != self.client.client_id[0]:
try:
self.client.retry(self.client.delete, path)
except NoNodeError:
pass
except:
return False
create = True
if not create and data == self._my_member_data:
return True
try:
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True)
if create:
self.client.retry(self.client.create, path, data, makepath=True, ephemeral=True)
else:
self.client.retry(self.client.set, path, data)
self._my_member_data = data
return True
except NodeExistsError:
try:
self.client.retry(self.client.delete, path)
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True)
self.client.retry(self.client.set, path, data)
self._my_member_data = data
return True
except:
logger.exception('touch_member')
except:
logger.exception('touch_member')
return False
def take_leader(self):
return self.attempt_to_acquire_leader()
def update_leader(self, state_handler):
last_operation = state_handler.last_operation().encode('utf-8')
def write_leader_optime(self, last_operation):
last_operation = last_operation.encode('utf-8')
if last_operation != self.last_leader_operation:
self.last_leader_operation = last_operation
path = self.leader_optime_path
@@ -225,11 +259,14 @@ class ZooKeeper(AbstractDCS):
logger.exception('Failed to create %s', path)
except:
logger.exception('Failed to update %s', path)
def update_leader(self):
return True
def delete_leader(self):
if isinstance(self.cluster, Cluster) and self.cluster.leader.name == self._name:
self.client.delete(self.leader_path, version=self.cluster.leader.index)
self.client.restart()
self._my_member_data = None
return True
def _cancel_initialization(self):
node = self.get_node(self.initialize_path)
@@ -243,8 +280,6 @@ class ZooKeeper(AbstractDCS):
logger.exception("Unable to delete initialize key")
def watch(self, timeout):
self.cluster_event.wait(timeout)
if self.cluster_event.isSet():
if super(ZooKeeper, self).watch(timeout):
self.fetch_cluster = True
return not self.cluster or not self.cluster.leader or self.cluster.leader.name != self._name
return False
return self.fetch_cluster
+60 -8
View File
@@ -10,6 +10,10 @@ from test_postgresql import psycopg2_connect, MockCursor
class MockPostgresql(Mock):
name = 'test'
state = 'running'
role = 'master'
def connection(self):
return psycopg2_connect()
@@ -17,9 +21,29 @@ class MockPostgresql(Mock):
return True
class MockHa(Mock):
dcs = Mock()
state_handler = MockPostgresql()
def schedule_restart(self):
return 'restart'
def schedule_reinitialize(self):
return 'reinitialize'
def restart(self):
return (True, '')
def restart_scheduled(self):
return False
class MockPatroni:
postgresql = MockPostgresql()
ha = MockHa()
dcs = Mock()
class MockRequest:
@@ -47,18 +71,46 @@ class MockRestApiServer(RestApiServer):
class TestRestApiHandler(unittest.TestCase):
def test_do_GET(self):
MockRestApiServer(RestApiHandler, b'GET /')
with patch.object(RestApiServer, 'query', Mock(side_effect=psycopg2.OperationalError())):
MockRestApiServer(RestApiHandler, b'GET /')
def test_do_GET_sampleauth(self):
MockRestApiServer(RestApiHandler, b'GET /sampleauth')
MockRestApiServer(RestApiHandler, b'GET /sampleauth\nAuthorization:')
MockRestApiServer(RestApiHandler, b'GET /sampleauth\nAuthorization: Basic dGVzdDp0ZXN0')
MockRestApiServer(RestApiHandler, b'GET /replica')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={})):
MockRestApiServer(RestApiHandler, b'GET /replica')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, b'GET /replica')
MockRestApiServer(RestApiHandler, b'GET /master')
MockPatroni.dcs.cluster.leader.name = MockPostgresql.name
MockRestApiServer(RestApiHandler, b'GET /replica')
MockPatroni.dcs.cluster = None
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, b'GET /master')
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, b'GET /master')
MockRestApiServer(RestApiHandler, b'GET /master')
def test_do_GET_patroni(self):
MockRestApiServer(RestApiHandler, b'GET /patroni')
def test_basicauth(self):
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0')
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0\nAuthorization:')
def test_do_POST_restart(self):
request = b'POST /restart HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'restart', Mock(side_effect=Exception)):
MockRestApiServer(RestApiHandler, request)
@patch.object(MockHa, 'dcs')
def test_do_POST_reinitialize(self, dcs):
cluster = dcs.get_cluster.return_value
request = b'POST /reinitialize HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
MockRestApiServer(RestApiHandler, request)
cluster.is_unlocked.return_value = False
MockRestApiServer(RestApiHandler, request)
with patch.object(MockHa, 'schedule_reinitialize', Mock(return_value=None)):
MockRestApiServer(RestApiHandler, request)
cluster.leader.name = 'test'
MockRestApiServer(RestApiHandler, request)
@patch('time.sleep', Mock())
def test_RestApiServer_query(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
+18
View File
@@ -0,0 +1,18 @@
import unittest
from mock import Mock, patch
from patroni.async_executor import AsyncExecutor
from threading import Thread
class TestAsyncExecutor(unittest.TestCase):
def setUp(self):
self.a = AsyncExecutor()
@patch.object(Thread, 'start', Mock())
def test_run_async(self):
self.a.run_async(Mock(return_value=True))
def test_run(self):
self.a.run(Mock(side_effect=Exception()))
+10 -14
View File
@@ -1,4 +1,3 @@
import datetime
import etcd
import json
import requests
@@ -8,7 +7,7 @@ import unittest
from dns.exception import DNSException
from mock import Mock, patch
from patroni.dcs import Cluster, DCSError, Leader, Member
from patroni.dcs import Cluster, DCSError, Leader
from patroni.etcd import Client, Etcd
@@ -50,6 +49,8 @@ def requests_get(url, **kwargs):
response = MockResponse()
if url.startswith('http://local'):
raise requests.exceptions.RequestException()
elif ':8011/patroni' in url:
response.content = '{"role": "replica", "xlog": {"replayed_location": 0}}'
elif url.endswith('/members'):
if url.startswith('http://error'):
response.content = '[{}]'
@@ -92,6 +93,8 @@ def etcd_read(key, **kwargs):
raise etcd.EtcdKeyNotFound
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
{"key": "/service/batman5/failover", "value": "",
"modifiedIndex": 1582, "createdIndex": 1582},
{"key": "/service/batman5/initialize", "value": "postgresql0",
"modifiedIndex": 1582, "createdIndex": 1582},
{"key": "/service/batman5/leader", "value": "postgresql1",
@@ -145,15 +148,6 @@ def http_request(method, url, **kwargs):
raise socket.error
class TestMember(unittest.TestCase):
def test_real_ttl(self):
now = datetime.datetime.utcnow()
member = Member(0, 'a', 'b', 'c', (now + datetime.timedelta(seconds=2)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), None)
self.assertLess(member.real_ttl(), 2)
self.assertEquals(Member(0, 'a', 'b', 'c', '', None).real_ttl(), -1)
@patch('dns.resolver.query', dns_query)
@patch('socket.getaddrinfo', socket_getaddrinfo)
@patch('requests.get', requests_get)
@@ -199,7 +193,6 @@ class TestClient(unittest.TestCase):
self.assertRaises(etcd.EtcdException, self.client._load_machines_cache)
@patch('time.sleep', Mock())
@patch('requests.get', requests_get)
class TestEtcd(unittest.TestCase):
@@ -242,8 +235,11 @@ class TestEtcd(unittest.TestCase):
self.etcd._base_path = '/service/failed'
self.assertFalse(self.etcd.attempt_to_acquire_leader())
def test_write_leader_optime(self):
self.etcd.write_leader_optime('0')
def test_update_leader(self):
self.assertTrue(self.etcd.update_leader(MockPostgresql()))
self.assertTrue(self.etcd.update_leader())
def test_initialize(self):
self.assertFalse(self.etcd.initialize())
@@ -256,7 +252,7 @@ class TestEtcd(unittest.TestCase):
def test_watch(self):
self.etcd.client.watch = etcd_watch
self.etcd.watch(100)
self.etcd.watch(0)
self.etcd.get_cluster()
self.etcd.watch(1.5)
self.etcd.watch(4.5)
+150 -28
View File
@@ -1,11 +1,11 @@
import unittest
from mock import Mock, patch
from patroni.dcs import Cluster, DCSError, Leader, Member
from patroni.dcs import Cluster, Failover, Leader, Member
from patroni.etcd import Client, Etcd
from patroni.exceptions import PostgresException
from patroni.exceptions import DCSError, PostgresException
from patroni.ha import Ha
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
def true(*args, **kwargs):
@@ -16,28 +16,33 @@ def false(*args, **kwargs):
return False
def get_cluster(initialize, leader):
return Cluster(initialize, leader, None, None)
def get_cluster(initialize, leader, members, failover):
return Cluster(initialize, leader, None, members, failover)
def get_cluster_not_initialized_without_leader():
return get_cluster(None, None)
return get_cluster(None, None, [], None)
def get_cluster_initialized_without_leader():
return get_cluster(True, None)
def get_cluster_initialized_without_leader(leader=False, failover=None):
m = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
'api_url': 'http://127.0.0.1:8008/patroni'})
l = Leader(0, 0, m) if leader else None
o = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
'api_url': 'http://127.0.0.1:8011/patroni'})
return get_cluster(True, l, [m, o], failover)
def get_cluster_initialized_with_leader():
return get_cluster(True, Leader(0, 0, 0,
Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres',
None, None, 28)))
def get_cluster_initialized_with_leader(failover=None):
return get_cluster_initialized_without_leader(leader=True, failover=failover)
class MockPostgresql(Mock):
name = 'postgresql0'
role = 'replica'
state = 'running'
connection_string = 'postgres://foo@bar/postgres'
def is_healthy(self):
return True
@@ -51,6 +56,9 @@ class MockPostgresql(Mock):
def is_leader(self):
return True
def xlog_position(self):
return 0
def last_operation(self):
return 0
@@ -60,6 +68,25 @@ class MockPostgresql(Mock):
def bootstrap(self, *args, **kwargs):
return True
def check_replication_lag(self, last_leader_operation):
return True
def check_recovery_conf(self, leader):
return False
class MockPatroni:
def __init__(self, p, d):
self.postgresql = p
self.dcs = d
self.api = Mock()
self.api.connection_string = 'http://127.0.0.1:8008'
def run_async(func, args=()):
func(*args) if args else func()
class TestHa(unittest.TestCase):
@@ -71,23 +98,37 @@ class TestHa(unittest.TestCase):
self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
self.e.client.read = etcd_read
self.e.client.write = etcd_write
self.ha = Ha(self.p, self.e)
self.ha.load_cluster_from_dcs()
self.ha = Ha(MockPatroni(self.p, self.e))
self.ha._async_executor.run_async = run_async
self.ha.old_cluster = self.e.get_cluster()
self.ha.cluster = get_cluster_not_initialized_without_leader()
self.ha.load_cluster_from_dcs = Mock()
def test_load_cluster_from_dcs(self):
ha = Ha(self.p, self.e)
ha.load_cluster_from_dcs()
self.e.get_cluster = get_cluster_not_initialized_without_leader
ha.load_cluster_from_dcs()
def test_update_lock(self):
self.p.last_operation = Mock(side_effect=PostgresException(''))
self.assertTrue(self.ha.update_lock())
def test_start_as_slave(self):
def test_touch_member(self):
self.p.xlog_position = Mock(side_effect=Exception)
self.ha.touch_member()
def test_start_as_replica(self):
self.p.is_healthy = false
self.assertEquals(self.ha.run_cycle(), 'started as a secondary')
def test_recover_replica_failed(self):
self.p.is_healthy = false
self.p.start = false
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
def test_recover_master_failed(self):
self.p.is_healthy = false
self.p.start = false
self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'removed leader key after trying and failing to start postgres')
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
def test_start_as_readonly(self):
self.ha.cluster.is_unlocked = false
self.p.is_leader = self.p.is_healthy = false
self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
@@ -96,6 +137,7 @@ class TestHa(unittest.TestCase):
self.assertEquals(self.ha.run_cycle(), 'acquired session lock as a leader')
def test_promoted_by_acquiring_lock(self):
self.ha.is_healthiest_node = true
self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
@@ -104,16 +146,17 @@ class TestHa(unittest.TestCase):
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock')
def test_follow_new_leader_after_failing_to_obtain_lock(self):
self.ha.is_healthiest_node = true
self.ha.acquire_lock = false
self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock')
def test_demote_because_not_healthiest(self):
self.p.is_healthiest_node = false
self.ha.is_healthiest_node = false
self.assertEquals(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
def test_follow_new_leader_because_not_healthiest(self):
self.p.is_healthiest_node = false
self.ha.is_healthiest_node = false
self.p.is_leader = false
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
@@ -148,13 +191,9 @@ class TestHa(unittest.TestCase):
self.assertEquals(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
def test_bootstrap_from_leader(self):
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.bootstrap(), 'bootstrapped from leader')
def test_bootstrap_from_leader_failed(self):
self.ha.cluster = get_cluster_initialized_with_leader()
self.p.bootstrap = false
self.assertEquals(self.ha.bootstrap(), 'failed to bootstrap from leader')
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from leader')
def test_bootstrap_waiting_for_leader(self):
self.ha.cluster = get_cluster_initialized_without_leader()
@@ -174,3 +213,86 @@ class TestHa(unittest.TestCase):
self.e.initialize = true
self.p.bootstrap = Mock(side_effect=PostgresException("Could not bootstrap master PostgreSQL"))
self.assertRaises(PostgresException, self.ha.bootstrap)
def test_reinitialize(self):
self.ha.schedule_reinitialize()
self.ha.schedule_reinitialize()
self.ha.run_cycle()
self.assertIsNone(self.ha._async_executor.scheduled_action)
self.ha.cluster = get_cluster_initialized_with_leader()
self.ha.has_lock = true
self.ha.schedule_reinitialize()
self.ha.run_cycle()
self.assertIsNone(self.ha._async_executor.scheduled_action)
self.ha.has_lock = false
self.ha.schedule_reinitialize()
self.ha.run_cycle()
def test_restart(self):
self.assertEquals(self.ha.restart(), (True, 'restarted successfully'))
self.p.restart = false
self.assertEquals(self.ha.restart(), (False, 'restart failed'))
self.ha.schedule_reinitialize()
self.assertEquals(self.ha.restart(), (False, 'reinitialize already in progress'))
def test_restart_in_progress(self):
self.ha._async_executor.schedule('restart', True)
self.assertTrue(self.ha.restart_scheduled())
self.assertEquals(self.ha.run_cycle(), 'not healthy enough for leader race')
self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEquals(self.ha.run_cycle(), 'restart in progress')
self.ha.has_lock = true
self.assertEquals(self.ha.run_cycle(), 'updated leader lock during restart')
self.ha.update_lock = false
self.assertEquals(self.ha.run_cycle(), 'failed to update leader lock during restart')
@patch('requests.get', requests_get)
def test_manual_failover_from_leader(self):
self.ha.has_lock = true
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', ''))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', MockPostgresql.name))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla'))
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
f = Failover(0, MockPostgresql.name, '')
self.ha.cluster = get_cluster_initialized_with_leader(f)
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
@patch('requests.get', requests_get)
def test_manual_failover_process_no_leader(self):
self.p.is_leader = false
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', MockPostgresql.name))
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader'))
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.ha.fetch_node_status = lambda e: (e, True, True, 0) # accessible, in_recovery
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, MockPostgresql.name, ''))
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
self.ha.fetch_node_status = lambda e: (e, False, True, 0) # accessible, in_recovery
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
def test__is_healthiest_node(self):
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.p.is_leader = false
self.ha.fetch_node_status = lambda e: (e, True, True, 0) # accessible, in_recovery
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.fetch_node_status = lambda e: (e, True, False, 0) # accessible, not in_recovery
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.fetch_node_status = lambda e: (e, True, True, 1) # accessible, in_recovery, xlog location ahead
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.p.check_replication_lag = false
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
@patch('requests.get', requests_get)
def test_fetch_node_status(self):
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
self.ha.fetch_node_status(member)
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
self.ha.fetch_node_status(member)
+6 -28
View File
@@ -1,4 +1,3 @@
import datetime
import sys
import time
import unittest
@@ -6,7 +5,7 @@ import yaml
from mock import Mock, patch
from patroni.api import RestApiServer
from patroni.dcs import Cluster, Member
from patroni.async_executor import AsyncExecutor
from patroni.etcd import Etcd
from patroni import Patroni, main
from patroni.zookeeper import ZooKeeper
@@ -26,6 +25,7 @@ def time_sleep(*args):
@patch.object(Postgresql, 'write_pg_hba', Mock())
@patch.object(Postgresql, 'write_recovery_conf', Mock())
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
@patch.object(AsyncExecutor, 'run', Mock())
class TestPatroni(unittest.TestCase):
@patch.object(Client, 'machines')
@@ -48,7 +48,6 @@ class TestPatroni(unittest.TestCase):
self.assertRaises(Exception, self.p.get_dcs, '', {})
@patch('time.sleep', Mock(side_effect=SleepException()))
@patch.object(Patroni, 'initialize', Mock())
@patch.object(Etcd, 'delete_leader', Mock())
@patch.object(Client, 'machines')
def test_patroni_main(self, mock_machines):
@@ -56,16 +55,13 @@ class TestPatroni(unittest.TestCase):
sys.argv = ['patroni.py', 'postgres0.yml']
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
with patch.object(Patroni, 'touch_member', self.touch_member):
with patch.object(Patroni, 'run', Mock(side_effect=SleepException())):
self.assertRaises(SleepException, main)
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
main()
with patch.object(Patroni, 'run', Mock(side_effect=SleepException())):
self.assertRaises(SleepException, main)
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
main()
@patch('time.sleep', Mock(side_effect=SleepException()))
def test_run(self):
self.p.touch_member = self.touch_member
self.p.ha.state_handler.sync_replication_slots = time_sleep
self.p.ha.dcs.watch = time_sleep
self.assertRaises(SleepException, self.p.run)
@@ -73,24 +69,6 @@ class TestPatroni(unittest.TestCase):
self.p.api.start = Mock()
self.assertRaises(SleepException, self.p.run)
def touch_member(self, ttl=None):
if not self.touched:
self.touched = True
return False
return True
def test_touch_member(self):
self.p.touch_member()
now = datetime.datetime.utcnow()
member = Member(0, self.p.postgresql.name, 'b', 'c', (now + datetime.timedelta(
seconds=self.p.shutdown_member_ttl + 10)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), None)
self.p.ha.cluster = Cluster(True, member, 0, [member])
self.p.touch_member()
def test_patroni_initialize(self):
self.p.touch_member = self.touch_member
self.p.initialize()
def test_schedule_next_run(self):
self.p.ha.dcs.watch = Mock(return_value=True)
self.p.schedule_next_run()
+48 -38
View File
@@ -25,18 +25,9 @@ class MockCursor:
raise RetryFailedError('retry')
elif sql.startswith('SELECT slot_name'):
self.results = [('blabla',), ('foobar',)]
elif sql.startswith('SELECT pg_current_xlog_location()'):
self.results = [(0,)]
elif sql.startswith('SELECT pg_is_in_recovery(), %s'):
if params[0][0] == 1:
raise psycopg2.OperationalError()
elif params[0][0] == 2:
self.results = [(True, -1)]
else:
self.results = [(False, 0)]
elif sql.startswith('SELECT pg_xlog_location_diff'):
self.results = [(0,)]
elif sql.startswith('SELECT pg_is_in_recovery()'):
elif sql == 'SELECT pg_is_in_recovery()':
self.results = [(False, )]
elif sql.startswith('SELECT to_char(pg_postmaster_start_time'):
self.results = [('', True, '', '', '', False)]
@@ -79,16 +70,24 @@ class MockConnect(Mock):
def cursor(self):
return MockCursor(self)
def __enter__(self):
return self
def __exit__(self, *args):
pass
def psycopg2_connect(*args, **kwargs):
return MockConnect()
@patch('subprocess.call', Mock(return_value=0))
@patch('shutil.copy', Mock())
@patch('psycopg2.connect', psycopg2_connect)
@patch('shutil.copy', Mock())
class TestPostgresql(unittest.TestCase):
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
def setUp(self):
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0',
'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432',
@@ -106,10 +105,10 @@ class TestPostgresql(unittest.TestCase):
'restore': 'true'})
if not os.path.exists(self.p.data_dir):
os.makedirs(self.p.data_dir)
self.leadermem = Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres', None, None, 28)
self.leader = Leader(-1, None, 28, self.leadermem)
self.other = Member(0, 'test1', 'postgres://replicator:[email protected]:5433/postgres', None, None, 28)
self.me = Member(0, 'test0', 'postgres://replicator:[email protected]:5434/postgres', None, None, 28)
self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
self.leader = Leader(-1, 28, self.leadermem)
self.other = Member(0, 'test1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres'})
self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'})
def tearDown(self):
shutil.rmtree('data')
@@ -121,23 +120,33 @@ class TestPostgresql(unittest.TestCase):
self.assertTrue(self.p.initialize())
self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf')))
def test_start_stop(self):
self.assertFalse(self.p.start())
self.p.is_running = false
with open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w'):
pass
def test_start(self):
self.assertTrue(self.p.start())
self.p.is_running = false
open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w').close()
self.assertTrue(self.p.start())
def test_stop(self):
self.assertTrue(self.p.stop())
with patch('subprocess.call', Mock(return_value=1)):
self.assertTrue(self.p.stop())
self.p.is_running = Mock(return_value=True)
self.assertFalse(self.p.stop())
def test_restart(self):
self.p.start = false
self.p.is_running = false
self.assertFalse(self.p.restart())
self.assertEquals(self.p.state, 'restart failed (restarting)')
def test_sync_from_leader(self):
self.assertTrue(self.p.sync_from_leader(self.leader))
def test_follow_the_leader(self):
self.p.demote(self.leader)
self.p.follow_the_leader(None)
self.p.demote(self.leader)
self.p.follow_the_leader(self.leader)
self.p.follow_the_leader(Leader(-1, None, 28, self.other))
self.p.demote()
self.p.follow_the_leader(self.leader)
self.p.follow_the_leader(Leader(-1, 28, self.other))
def test_create_replica(self):
self.p.delete_trigger_file = Mock(side_effect=OSError())
@@ -151,29 +160,25 @@ class TestPostgresql(unittest.TestCase):
def test_sync_replication_slots(self):
self.p.start()
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem])
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem], None)
self.p.sync_replication_slots(cluster)
self.p.query = Mock(side_effect=psycopg2.OperationalError)
self.p.schedule_load_slots = True
self.p.sync_replication_slots(cluster)
@patch.object(MockConnect, 'closed', 2)
def test__query(self):
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
self.p._state = 'restarting'
self.assertRaises(RetryFailedError, self.p._query, 'blabla')
def test_query(self):
self.p.query('select 1')
self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
def test_is_healthiest_node(self):
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem])
self.assertTrue(self.p.is_healthiest_node(cluster))
self.p.is_leader = false
self.assertFalse(self.p.is_healthiest_node(cluster))
self.p.xlog_position = lambda: 1
self.assertTrue(self.p.is_healthiest_node(cluster))
self.p.xlog_position = lambda: 2
self.assertFalse(self.p.is_healthiest_node(cluster))
self.p.config['maximum_lag_on_failover'] = -3
self.assertFalse(self.p.is_healthiest_node(cluster))
def test_is_leader(self):
self.assertTrue(self.p.is_leader())
def test_reload(self):
self.assertTrue(self.p.reload())
@@ -184,6 +189,7 @@ class TestPostgresql(unittest.TestCase):
self.assertFalse(self.p.is_healthy())
def test_promote(self):
self.p._role = 'replica'
self.assertTrue(self.p.promote())
self.assertTrue(self.p.promote())
@@ -202,6 +208,9 @@ class TestPostgresql(unittest.TestCase):
self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported"))
self.assertTrue(self.p.stop())
def test_check_replication_lag(self):
self.assertTrue(self.p.check_replication_lag(0))
@patch('os.rename', Mock())
@patch('os.path.isdir', Mock(return_value=True))
def test_move_data_directory(self):
@@ -211,9 +220,10 @@ class TestPostgresql(unittest.TestCase):
self.p.move_data_directory()
def test_bootstrap(self):
self.assertRaises(PostgresException, self.p.bootstrap)
self.p.start = Mock(return_value=True)
with patch('subprocess.call', Mock(return_value=1)):
self.assertRaises(PostgresException, self.p.bootstrap)
self.p.bootstrap()
self.p.bootstrap(self.leader)
def test_remove_data_directory(self):
self.p.data_dir = 'data_dir'
+40 -10
View File
@@ -7,7 +7,7 @@ from patroni.zookeeper import ExhibitorEnsembleProvider, ZooKeeper, ZooKeeperErr
from kazoo.client import KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError
from kazoo.protocol.states import ZnodeStat
from test_etcd import MockPostgresql, SleepException, requests_get
from test_etcd import SleepException, requests_get
class MockKazooClient(Mock):
@@ -31,7 +31,7 @@ class MockKazooClient(Mock):
elif '/members/' in path:
return (
b'postgres://repuser:rep-pass@localhost:5434/postgres?application_name=http://127.0.0.1:8009/patroni',
ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0 if self.exists else -1, 0, 0, 0)
)
elif path.endswith('/optime/leader'):
return (b'1', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
@@ -41,14 +41,15 @@ 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))
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
def get_children(self, path, watch=None, include_data=False):
if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)")
if path == '/no_node':
if path.startswith('/no_node'):
raise NoNodeError
elif path in ['/service/bla/', '/service/test/']:
return ['initialize', 'leader', 'members', 'optime']
return ['initialize', 'leader', 'members', 'optime', 'failover']
return ['foo', 'bar', 'buzz']
def create(self, path, value=b"", acl=None, ephemeral=False, sequence=False, makepath=False):
@@ -68,6 +69,14 @@ class MockKazooClient(Mock):
raise TypeError("Invalid type for 'value' (must be a byte string)")
if path == '/service/bla/optime/leader':
raise Exception
if path == '/service/test/members/bar':
if value == b'retry':
return
if path == '/service/test/failover':
if value == b'Exception':
raise Exception
elif value == b'ok':
return
raise NoNodeError
def delete(self, path, version=-1, recursive=False):
@@ -79,7 +88,9 @@ class MockKazooClient(Mock):
return
self.leader = True
raise Exception
elif path.endswith('/initialize'):
elif path == '/service/test/members/buzz':
raise Exception
elif path.endswith('/initialize') or path == '/service/test/members/bar':
raise NoNodeError
@@ -110,6 +121,8 @@ 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 = '/no_node'
self.zk._inner_load_cluster()
def test_get_cluster(self):
self.assertRaises(ZooKeeperError, self.zk.get_cluster)
@@ -119,6 +132,11 @@ class TestZooKeeper(unittest.TestCase):
self.zk.touch_member('foo')
self.zk.delete_leader()
def test_set_failover_value(self):
self.zk.set_failover_value('')
self.zk.set_failover_value('ok')
self.zk.set_failover_value('Exception')
def test_initialize(self):
self.assertFalse(self.zk.initialize())
@@ -126,21 +144,33 @@ class TestZooKeeper(unittest.TestCase):
self.zk.cancel_initialization()
def test_touch_member(self):
self.zk._name = 'buzz'
self.zk.get_cluster()
self.zk.touch_member('new')
self.zk._name = 'bar'
self.zk.touch_member('new')
self.zk._name = 'na'
self.zk.client.exists = 1
self.zk.touch_member('exists')
self.zk._name = 'bar'
self.zk.touch_member('retry')
self.zk.fetch_cluster = True
self.zk.get_cluster()
self.zk.touch_member('retry')
def test_take_leader(self):
self.zk.take_leader()
def test_update_leader(self):
self.zk.last_leader_operation = -1
self.assertTrue(self.zk.update_leader(MockPostgresql()))
self.assertTrue(self.zk.update_leader())
def test_write_leader_optime(self):
self.zk.last_leader_operation = '0'
self.zk.write_leader_optime('1')
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk.last_leader_operation = -1
self.assertTrue(self.zk.update_leader(MockPostgresql()))
self.zk.write_leader_optime('2')
def test_watch(self):
self.zk.watch(0)
self.zk.cluster_event.isSet = lambda: False
self.zk.event.isSet = lambda: True
self.zk.watch(0)