Skip to content

Commit 741f246

Browse files
committed
feat: add support for persistent recursive watches
ZooKeeper 3.6.0 added support for persistent, and persistent recursive watches. This adds the corresponding support to the Kazoo client class.
1 parent 33c348b commit 741f246

File tree

6 files changed

+326
-8
lines changed

6 files changed

+326
-8
lines changed

kazoo/client.py

+99
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from kazoo.protocol.connection import ConnectionHandler
2626
from kazoo.protocol.paths import _prefix_root, normpath
2727
from kazoo.protocol.serialization import (
28+
AddWatch,
2829
Auth,
2930
CheckVersion,
3031
CloseInstance,
@@ -38,6 +39,7 @@
3839
SetACL,
3940
GetData,
4041
Reconfig,
42+
RemoveWatches,
4143
SetData,
4244
Sync,
4345
Transaction,
@@ -248,6 +250,7 @@ def __init__(
248250
self.state_listeners = set()
249251
self._child_watchers = defaultdict(set)
250252
self._data_watchers = defaultdict(set)
253+
self._persistent_watchers = defaultdict(set)
251254
self._reset()
252255
self.read_only = read_only
253256

@@ -416,8 +419,12 @@ def _reset_watchers(self):
416419
for data_watchers in self._data_watchers.values():
417420
watchers.extend(data_watchers)
418421

422+
for persistent_watchers in self._persistent_watchers.values():
423+
watchers.extend(persistent_watchers)
424+
419425
self._child_watchers = defaultdict(set)
420426
self._data_watchers = defaultdict(set)
427+
self._persistent_watchers = defaultdict(set)
421428

422429
ev = WatchedEvent(EventType.NONE, self._state, None)
423430
for watch in watchers:
@@ -1644,8 +1651,100 @@ def reconfig_async(self, joining, leaving, new_members, from_config):
16441651

16451652
return async_result
16461653

1654+
def add_watch(self, path, watch, mode):
1655+
"""Add a watch.
1656+
1657+
This method adds persistent watches. Unlike the data and
1658+
child watches which may be set by calls to
1659+
:meth:`KazooClient.exists`, :meth:`KazooClient.get`, and
1660+
:meth:`KazooClient.get_children`, persistent watches are not
1661+
removed after being triggered.
1662+
1663+
To remove a persistent watch, use
1664+
:meth:`KazooClient.remove_all_watches` with an argument of
1665+
:attr:`~kazoo.states.WatcherType.ANY`.
1666+
1667+
The `mode` argument determines whether or not the watch is
1668+
recursive. To set a persistent watch, use
1669+
:class:`~kazoo.states.AddWatchMode.PERSISTENT`. To set a
1670+
persistent recursive watch, use
1671+
:class:`~kazoo.states.AddWatchMode.PERSISTENT_RECURSIVE`.
1672+
1673+
:param path: Path of node to watch.
1674+
:param watch: Watch callback to set for future changes
1675+
to this path.
1676+
:param mode: The mode to use.
1677+
:type mode: int
1678+
1679+
:raises:
1680+
:exc:`~kazoo.exceptions.MarshallingError` if mode is
1681+
unknown.
1682+
1683+
:exc:`~kazoo.exceptions.ZookeeperError` if the server
1684+
returns a non-zero error code.
1685+
"""
1686+
return self.add_watch_async(path, watch, mode).get()
1687+
1688+
def add_watch_async(self, path, watch, mode):
1689+
"""Asynchronously add a watch. Takes the same arguments as
1690+
:meth:`add_watch`.
1691+
"""
1692+
if not isinstance(path, str):
1693+
raise TypeError("Invalid type for 'path' (string expected)")
1694+
if not callable(watch):
1695+
raise TypeError("Invalid type for 'watch' (must be a callable)")
1696+
if not isinstance(mode, int):
1697+
raise TypeError("Invalid type for 'mode' (int expected)")
1698+
1699+
async_result = self.handler.async_result()
1700+
self._call(
1701+
AddWatch(_prefix_root(self.chroot, path), watch, mode),
1702+
async_result,
1703+
)
1704+
return async_result
1705+
1706+
def remove_all_watches(self, path, watcher_type):
1707+
"""Remove watches from a path.
1708+
1709+
This removes all watches of a specified type (data, child,
1710+
any) from a given path.
1711+
1712+
The `watcher_type` argument specifies which type to use. It
1713+
may be one of:
1714+
1715+
* :attr:`~kazoo.states.WatcherType.DATA`
1716+
* :attr:`~kazoo.states.WatcherType.CHILD`
1717+
* :attr:`~kazoo.states.WatcherType.ANY`
1718+
1719+
To remove persistent watches, specify a watcher type of
1720+
:attr:`~kazoo.states.WatcherType.ANY`.
1721+
1722+
:param path: Path of watch to remove.
1723+
:param watcher_type: The type of watch to remove.
1724+
:type watcher_type: int
1725+
"""
1726+
1727+
return self.remove_all_watches_async(path, watcher_type).get()
1728+
1729+
def remove_all_watches_async(self, path, watcher_type):
1730+
"""Asynchronously remove watches. Takes the same arguments as
1731+
:meth:`remove_all_watches`.
1732+
"""
1733+
if not isinstance(path, str):
1734+
raise TypeError("Invalid type for 'path' (string expected)")
1735+
if not isinstance(watcher_type, int):
1736+
raise TypeError("Invalid type for 'watcher_type' (int expected)")
1737+
1738+
async_result = self.handler.async_result()
1739+
self._call(
1740+
RemoveWatches(_prefix_root(self.chroot, path), watcher_type),
1741+
async_result,
1742+
)
1743+
return async_result
1744+
16471745

16481746
class TransactionRequest(object):
1747+
16491748
"""A Zookeeper Transaction Request
16501749
16511750
A Transaction provides a builder object that can be used to

kazoo/exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ class NotReadOnlyCallError(ZookeeperError):
187187
a read-only server"""
188188

189189

190+
@_zookeeper_exception(-121)
191+
class NoWatcherError(ZookeeperError):
192+
"""No watcher was found at the supplied path"""
193+
194+
190195
class ConnectionClosedError(SessionExpiredError):
191196
"""Connection is closed"""
192197

kazoo/protocol/connection.py

+35-7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from kazoo.loggingsupport import BLATHER
2222
from kazoo.protocol.serialization import (
23+
AddWatch,
2324
Auth,
2425
Close,
2526
Connect,
@@ -28,6 +29,7 @@
2829
GetChildren2,
2930
Ping,
3031
PingInstance,
32+
RemoveWatches,
3133
ReplyHeader,
3234
SASL,
3335
Transaction,
@@ -363,6 +365,18 @@ def _write(self, msg, timeout):
363365
raise ConnectionDropped("socket connection broken")
364366
sent += bytes_sent
365367

368+
def _find_persistent_watchers(self, path):
369+
parts = path.split("/")
370+
watchers = []
371+
for count in range(len(parts)):
372+
candidate = "/".join(parts[: count + 1])
373+
if not candidate:
374+
continue
375+
watchers.extend(
376+
self.client._persistent_watchers.get(candidate, [])
377+
)
378+
return watchers
379+
366380
def _read_watch_event(self, buffer, offset):
367381
client = self.client
368382
watch, offset = Watch.deserialize(buffer, offset)
@@ -374,9 +388,11 @@ def _read_watch_event(self, buffer, offset):
374388

375389
if watch.type in (CREATED_EVENT, CHANGED_EVENT):
376390
watchers.extend(client._data_watchers.pop(path, []))
391+
watchers.extend(self._find_persistent_watchers(path))
377392
elif watch.type == DELETED_EVENT:
378393
watchers.extend(client._data_watchers.pop(path, []))
379394
watchers.extend(client._child_watchers.pop(path, []))
395+
watchers.extend(self._find_persistent_watchers(path))
380396
elif watch.type == CHILD_EVENT:
381397
watchers.extend(client._child_watchers.pop(path, []))
382398
else:
@@ -448,13 +464,25 @@ def _read_response(self, header, buffer, offset):
448464

449465
async_object.set(response)
450466

451-
# Determine if watchers should be registered
452-
watcher = getattr(request, "watcher", None)
453-
if not client._stopped.is_set() and watcher:
454-
if isinstance(request, (GetChildren, GetChildren2)):
455-
client._child_watchers[request.path].add(watcher)
456-
else:
457-
client._data_watchers[request.path].add(watcher)
467+
# Determine if watchers should be registered or unregistered
468+
if not client._stopped.is_set():
469+
watcher = getattr(request, "watcher", None)
470+
if watcher:
471+
if isinstance(request, AddWatch):
472+
client._persistent_watchers[request.path].add(watcher)
473+
elif isinstance(request, (GetChildren, GetChildren2)):
474+
client._child_watchers[request.path].add(watcher)
475+
else:
476+
client._data_watchers[request.path].add(watcher)
477+
if isinstance(request, RemoveWatches):
478+
if request.watcher_type == 1:
479+
client._child_watchers.pop(request.path, None)
480+
if request.watcher_type == 2:
481+
client._data_watchers.pop(request.path, None)
482+
if request.watcher_type == 3:
483+
client._child_watchers.pop(request.path, None)
484+
client._data_watchers.pop(request.path, None)
485+
client._persistent_watchers.pop(request.path, None)
458486

459487
if isinstance(request, Close):
460488
self.logger.log(BLATHER, "Read close response")

kazoo/protocol/serialization.py

+28
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,20 @@ def deserialize(cls, bytes, offset):
416416
return data, stat
417417

418418

419+
class RemoveWatches(namedtuple("RemoveWatches", "path watcher_type")):
420+
type = 18
421+
422+
def serialize(self):
423+
b = bytearray()
424+
b.extend(write_string(self.path))
425+
b.extend(int_struct.pack(self.watcher_type))
426+
return b
427+
428+
@classmethod
429+
def deserialize(cls, bytes, offset):
430+
return None
431+
432+
419433
class Auth(namedtuple("Auth", "auth_type scheme auth")):
420434
type = 100
421435

@@ -441,6 +455,20 @@ def deserialize(cls, bytes, offset):
441455
return challenge, offset
442456

443457

458+
class AddWatch(namedtuple("AddWatch", "path watcher mode")):
459+
type = 106
460+
461+
def serialize(self):
462+
b = bytearray()
463+
b.extend(write_string(self.path))
464+
b.extend(int_struct.pack(self.mode))
465+
return b
466+
467+
@classmethod
468+
def deserialize(cls, bytes, offset):
469+
return None
470+
471+
444472
class Watch(namedtuple("Watch", "type state path")):
445473
@classmethod
446474
def deserialize(cls, bytes, offset):

kazoo/protocol/states.py

+41
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,44 @@ def data_length(self):
251251
@property
252252
def children_count(self):
253253
return self.numChildren
254+
255+
256+
class AddWatchMode(object):
257+
"""Modes for use with :meth:`~kazoo.client.KazooClient.add_watch`
258+
259+
.. attribute:: PERSISTENT
260+
261+
The watch is not removed when trigged.
262+
263+
.. attribute:: PERSISTENT_RECURSIVE
264+
265+
The watch is not removed when trigged, and applies to all
266+
paths underneath the supplied path as well.
267+
"""
268+
269+
PERSISTENT = 0
270+
PERSISTENT_RECURSIVE = 1
271+
272+
273+
class WatcherType(object):
274+
"""Watcher types for use with
275+
:meth:`~kazoo.client.KazooClient.remove_all_watches`
276+
277+
.. attribute:: CHILDREN
278+
279+
Child watches.
280+
281+
.. attribute:: DATA
282+
283+
Data watches.
284+
285+
.. attribute:: ANY
286+
287+
Any type of watch (child, data, persistent, or persistent
288+
recursive).
289+
290+
"""
291+
292+
CHILDREN = 1
293+
DATA = 2
294+
ANY = 3

0 commit comments

Comments
 (0)