blob: 441f237bb749e5d45801c42756c8ffab2b3b2b36 [file] [log] [blame]
import Queue
import functools
import posixpath
import sys
import threading
from mysos.common import zookeeper
from kazoo.client import KazooClient
from kazoo.exceptions import NoNodeError
from kazoo.protocol.states import EventType
from kazoo.recipe.watchers import ChildrenWatch, DataWatch
from twitter.common import log
from twitter.common.zookeeper.serverset.endpoint import ServiceInstance
def get_cluster_path(zk_root, cluster_name):
"""
:param zk_root: the root path for the mysos scheduler.
:param cluster_name: Name of the the cluster.
:return: The path for the cluster.
"""
return posixpath.join(zk_root, cluster_name)
class Cluster(object):
"""
A class that represents all members of the MySQL cluster.
A newly added cluster member becomes a read-only slave until it's promoted to a master.
Only the master can write.
The members of the cluster are maintained in two ZooKeeper groups: a slaves group and a master
group under the same 'directory'. Slaves have unique member IDs backed by ZooKeeper's sequential
ZNodes. When a slave is promoted to a master, it is moved (its ID preserved) from the slave
group to the master group.
There is at most one member in the master group.
"""
SLAVES_GROUP = 'slaves'
MASTER_GROUP = 'master'
MEMBER_PREFIX = "member_" # Use the prefix so the path conforms to the ServerSet convention.
def __init__(self, cluster_path):
self.cluster_path = cluster_path
self.members = {} # {ID : (serialized) ServiceInstance} mappings for members of the cluster.
self.master = None # The master's member ID.
self.slaves_group = posixpath.join(cluster_path, self.SLAVES_GROUP)
self.master_group = posixpath.join(cluster_path, self.MASTER_GROUP)
# TODO(jyx): Handle errors e.g. sessions expirations and recoverable failures.
class ClusterManager(object):
"""
Kazoo wrapper used by the scheduler to inform executors about cluster change.
NOTE: ClusterManager is thread safe, i.e., it can be accessed from multiple threads at once.
"""
class Error(Exception): pass
def __init__(self, client, cluster_path):
"""
:param client: Kazoo client.
:param cluster_path: The path for this cluster on ZooKeeper.
"""
self._client = client
self._cluster = Cluster(cluster_path)
self._lock = threading.Lock()
self._populate()
def _read_child_content(self, group, member_id):
try:
return self._client.get(posixpath.join(group, member_id))[0]
except NoNodeError:
return None
def _populate(self):
self._client.ensure_path(self._cluster.slaves_group)
self._client.ensure_path(self._cluster.master_group)
# Populate slaves.
for child in self._client.get_children(self._cluster.slaves_group):
child_content = self._read_child_content(self._cluster.slaves_group, child)
if child_content:
self._cluster.members[child] = child_content
# Populate the master.
master_group = self._client.get_children(self._cluster.master_group)
assert len(master_group) <= 1
if len(master_group) == 1:
child = master_group[0]
child_content = self._read_child_content(self._cluster.master_group, child)
if child_content:
self._cluster.members[child] = child_content
self._cluster.master = child
def add_member(self, service_instance):
"""
Add the member to the ZooKeeper group.
NOTE:
- New members are slaves until being promoted.
- A new member is not added if the specified service_instance already exists in the group.
:return: The member ID for the ServiceInstance generated by ZooKeeper.
"""
if not isinstance(service_instance, ServiceInstance):
raise TypeError("'service_instance' should be a ServiceInstance")
content = ServiceInstance.pack(service_instance)
for k, v in self._cluster.members.items():
if content == v:
log.info("%s not added because it already exists in the group" % service_instance)
return k
znode_path = self._client.create(
posixpath.join(self._cluster.slaves_group, self._cluster.MEMBER_PREFIX),
content,
sequence=True)
_, member_id = posixpath.split(znode_path)
with self._lock:
self._cluster.members[member_id] = content
return member_id
def remove_member(self, member_id):
"""
Remove the member if it is in the group.
:return: True if the member is deleted. False if the member cannot be found.
"""
with self._lock:
if member_id not in self._cluster.members:
log.info("Member %s is not in the ZK group" % member_id)
return False
self._cluster.members.pop(member_id, None)
if member_id == self._cluster.master:
self._cluster.master = None
self._client.delete(posixpath.join(self._cluster.master_group, member_id))
else:
self._client.delete(posixpath.join(self._cluster.slaves_group, member_id))
return True
def promote_member(self, member_id):
"""
Promote the member with the given ID to be the master of the cluster if it's not already the
master.
:return: True if the member is promoted. False if the member is already the master.
"""
with self._lock:
if member_id not in self._cluster.members:
raise ValueError("Invalid member_id: %s" % member_id)
# Do nothing if the member is already the master.
if self._cluster.master and self._cluster.master == member_id:
log.info("Not promoting %s because is already the master" % member_id)
return False
tx = self._client.transaction()
if self._cluster.master:
tx.delete(posixpath.join(self._cluster.master_group, self._cluster.master))
self._cluster.members.pop(self._cluster.master)
# "Move" the ZNode, i.e., create a ZNode of the same ID in the master group.
tx.delete(posixpath.join(self._cluster.slaves_group, member_id))
tx.create(
posixpath.join(self._cluster.master_group, member_id),
self._cluster.members[member_id])
tx.commit()
self._cluster.master = member_id
return True
def delete_cluster(self):
with self._lock:
if self._cluster.members:
raise self.Error("Cannot remove a cluster that is not empty")
# Need to delete master/slave sub-dirs.
self._client.delete(self._cluster.cluster_path, recursive=True)
# TODO(wickman): Implement kazoo connection acquiescence.
class ClusterListener(object):
"""Kazoo wrapper used by the executor to listen to cluster change."""
def __init__(self,
client,
cluster_path,
self_instance=None,
promotion_callback=None,
demotion_callback=None,
master_callback=None,
termination_callback=None):
"""
:param client: Kazoo client.
:param cluster_path: The path for this cluster on ZooKeeper.
:param self_instance: The local ServiceInstance associated with this listener.
:param promotion_callback: Invoked when 'self_instance' is promoted.
:param demotion_callback: Invoked when 'self_instance' is demoted.
:param master_callback: Invoked when there is a master change otherwise.
:param termination_callback: Invoked when the cluster is terminated.
NOTE: Callbacks are executed synchronously in Kazoo's completion thread to ensure the delivery
order of events. Blocking the callback method means no future callbacks will be invoked.
"""
self._client = client
self._cluster = Cluster(cluster_path)
self._self_content = ServiceInstance.pack(self_instance) if self_instance else None
self._master = None
self._master_content = None
self._promotion_callback = promotion_callback or (lambda: True)
self._demotion_callback = demotion_callback or (lambda: True)
self._master_callback = master_callback or (lambda x: True)
self._termination_callback = termination_callback or (lambda: True)
self._children_watch = None # Set when the watcher detects that the master group exists.
def start(self):
"""
Start the listener to watch the master group.
NOTE: The listener only starts watching master after the base ZNode for the group is created.
"""
DataWatch(self._client, self._cluster.cluster_path, func=self._cluster_path_callback)
DataWatch(self._client, self._cluster.master_group, func=self._master_group_callback)
def _swap(self, master, master_content):
i_was_master = self._self_content and self._master_content == self._self_content
self._master, self._master_content = master, master_content
i_am_master = self._self_content and self._master_content == self._self_content
# Invoke callbacks accordingly.
# NOTE: No callbacks are invoked if there is currently no master and 'self_instance' wasn't the
# master.
if i_was_master and not i_am_master:
self._demotion_callback()
elif not i_was_master and i_am_master:
self._promotion_callback()
elif not i_was_master and not i_am_master and master:
assert master_content
self._master_callback(ServiceInstance.unpack(master_content))
def _data_callback(self, master_id, master_completion):
try:
master_content, _ = master_completion.get()
except NoNodeError:
# ZNode could be gone after we detected it but before we read it.
master_id, master_content = None, None
self._swap(master_id, master_content)
def _child_callback(self, masters):
assert len(masters) <= 1, "There should be at most one master"
if len(masters) == 1 and self._master != masters[0]:
self._client.get_async(posixpath.join(self._cluster.master_group, masters[0])).rawlink(
functools.partial(self._data_callback, masters[0]))
elif len(masters) == 0:
self._swap(None, None)
def _cluster_path_callback(self, data, stat, event):
if event and event.type == EventType.DELETED:
self._termination_callback()
def _master_group_callback(self, data, stat, event):
if stat and not self._children_watch:
log.info("Master group %s exists. Starting to watch for election result" %
self._cluster.master_group)
self._children_watch = ChildrenWatch(
self._client, self._cluster.master_group, func=self._child_callback)
def resolve_master(
cluster_url, master_callback=lambda: True, termination_callback=lambda: True, zk_client=None):
"""
Resolve the MySQL cluster master's endpoint from the given URL for this cluster.
:param cluster_url: The ZooKeeper URL for this cluster.
:param master_callback: A callback method with one argument: the ServiceInstance for the elected
master.
:param termination_callback: A callback method with no argument. Invoked when the cluster
terminates.
:param zk_client: Use a custom ZK client instead of Kazoo if specified.
"""
try:
_, zk_servers, cluster_path = zookeeper.parse(cluster_url)
except Exception as e:
raise ValueError("Invalid cluster_url: %s" % e.message)
if not zk_client:
zk_client = KazooClient(zk_servers)
zk_client.start()
listener = ClusterListener(
zk_client,
cluster_path,
None,
master_callback=master_callback,
termination_callback=termination_callback)
listener.start()
def wait_for_master(cluster_url, zk_client=None):
"""
Convenience function to wait for the master to be elected and return the master.
:param cluster_url: The ZooKeeper URL for this cluster.
:param zk_client: Use a custom ZK client instead of Kazoo if specified.
:return: The ServiceInstance for the elected master.
"""
master = Queue.Queue()
resolve_master(
cluster_url,
master_callback=lambda x: master.put(x),
termination_callback=lambda: True,
zk_client=zk_client)
# Block forever but using sys.maxint makes the wait interruptable by Ctrl-C. See
# http://bugs.python.org/issue1360.
return master.get(True, sys.maxint)
def wait_for_termination(cluster_url, zk_client=None):
"""
Convenience function to wait for the cluster to terminate. The corresponding ZNode is removed
when the cluster terminates.
:param cluster_url: The ZooKeeper URL for this cluster.
:param zk_client: Use a custom ZK client instead of Kazoo if specified.
"""
terminated = threading.Event()
resolve_master(
cluster_url,
master_callback=lambda x: True,
termination_callback=lambda: terminated.set(),
zk_client=zk_client)
# Block forever but using sys.maxint makes the wait interruptable by Ctrl-C. See
# http://bugs.python.org/issue1360.
terminated.wait(sys.maxint)