Store tenant layout state in Zookeeper

To coordinate reconfigurations across schedulers we need to store the
current layout state in Zookeeper. We store the hostname of the
scheduler that processed the reconfiguration event as well as the human
readable timestamp of the reconfig.

The ltime of the layout state is the last modified transaction ID of the
corresponding znode in Zookeeper. It will later be used to detect layout
changes on other schedulers.

Change-Id: I243aa4f54b038fbad78e331fbeeb508989e34b5f
This commit is contained in:
Simon Westphahl 2020-12-08 13:37:21 +01:00 committed by Clark Boylan
parent c946c26cef
commit 32f3d5fe35
7 changed files with 243 additions and 76 deletions

View File

@ -27,6 +27,7 @@ import github3.exceptions
from tests.fakegithub import FakeGithubEnterpriseClient
from zuul.driver.github.githubconnection import GithubShaCache
from zuul.zk.layout import LayoutState
import zuul.rpcclient
from zuul.lib import strings
@ -35,6 +36,8 @@ from tests.base import (AnsibleZuulTestCase, BaseTestCase,
simple_layout, random_sha1)
from tests.base import ZuulWebFixture
EMPTY_LAYOUT_STATE = LayoutState("", "", 0)
class TestGithubDriver(ZuulTestCase):
config_file = 'zuul-github-driver.conf'
@ -222,8 +225,8 @@ class TestGithubDriver(ZuulTestCase):
self.waitUntilSettled()
# Record previous tenant reconfiguration time
before = self.scheds.first.sched.tenant_last_reconfigured.get(
'tenant-one', 0)
before = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.fake_github.emitEvent(
self.fake_github.getPushEvent('org/project', 'refs/tags/newtag',
@ -231,8 +234,8 @@ class TestGithubDriver(ZuulTestCase):
self.waitUntilSettled()
# Make sure the tenant hasn't been reconfigured due to the new tag
after = self.scheds.first.sched.tenant_last_reconfigured.get(
'tenant-one', 0)
after = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.assertEqual(before, after)
build_params = self.builds[0].parameters
@ -968,8 +971,8 @@ class TestGithubDriver(ZuulTestCase):
removed_files=removed_files)
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
if expected_cat_jobs is not None:
@ -979,8 +982,8 @@ class TestGithubDriver(ZuulTestCase):
self.fake_github.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
if expect_reconfigure:
# New timestamp should be greater than the old timestamp
@ -1434,14 +1437,14 @@ class TestGithubUnprotectedBranches(ZuulTestCase):
modified_files=['zuul.yaml'])
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
self.fake_github.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# We don't expect a reconfiguration because the push was to an
# unprotected branch
@ -1454,8 +1457,8 @@ class TestGithubUnprotectedBranches(ZuulTestCase):
self.fake_github.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# We now expect that zuul reconfigured itself
self.assertLess(old, new)
@ -1479,8 +1482,8 @@ class TestGithubUnprotectedBranches(ZuulTestCase):
self.waitUntilSettled()
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
# Delete the branch
@ -1493,8 +1496,8 @@ class TestGithubUnprotectedBranches(ZuulTestCase):
self.fake_github.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# We now expect that zuul reconfigured itself as we deleted a protected
# branch
@ -1554,8 +1557,8 @@ class TestGithubUnprotectedBranches(ZuulTestCase):
removed_files=removed_files)
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
if expected_cat_jobs is not None:
@ -1565,8 +1568,8 @@ class TestGithubUnprotectedBranches(ZuulTestCase):
self.fake_github.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
if expect_reconfigure:
# New timestamp should be greater than the old timestamp

View File

@ -20,12 +20,15 @@ import socket
import zuul.rpcclient
from zuul.lib import strings
from zuul.zk.layout import LayoutState
from tests.base import random_sha1, simple_layout
from tests.base import ZuulTestCase, ZuulWebFixture
from testtools.matchers import MatchesRegex
EMPTY_LAYOUT_STATE = LayoutState("", "", 0)
class TestGitlabWebhook(ZuulTestCase):
config_file = 'zuul-gitlab-driver.conf'
@ -294,12 +297,12 @@ class TestGitlabDriver(ZuulTestCase):
event = self.fake_gitlab.getPushEvent(
'org/project', branch='refs/heads/stable-1.0',
before='0' * 40, after=newrev)
old = self.scheds.first.sched.tenant_last_reconfigured.get(
'tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.fake_gitlab.emitEvent(event)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured.get(
'tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# New timestamp should be greater than the old timestamp
self.assertLess(old, new)
self.assertEqual(1, len(self.history))
@ -383,8 +386,8 @@ class TestGitlabDriver(ZuulTestCase):
def test_ref_updated_and_tenant_reconfigure(self):
self.waitUntilSettled()
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
zuul_yaml = [
{'job': {
@ -410,8 +413,8 @@ class TestGitlabDriver(ZuulTestCase):
self.fake_gitlab.emitEvent(event)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# New timestamp should be greater than the old timestamp
self.assertLess(old, new)
@ -837,14 +840,14 @@ class TestGitlabUnprotectedBranches(ZuulTestCase):
branch='refs/heads/master')
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
self.fake_gitlab.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# We don't expect a reconfiguration because the push was to an
# unprotected branch
@ -855,8 +858,8 @@ class TestGitlabUnprotectedBranches(ZuulTestCase):
self.fake_gitlab.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# We now expect that zuul reconfigured itself
self.assertLess(old, new)
@ -881,8 +884,8 @@ class TestGitlabUnprotectedBranches(ZuulTestCase):
self.waitUntilSettled()
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
# Delete the branch
@ -895,8 +898,8 @@ class TestGitlabUnprotectedBranches(ZuulTestCase):
self.fake_gitlab.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# We now expect that zuul reconfigured itself as we deleted a protected
# branch
@ -955,8 +958,8 @@ class TestGitlabUnprotectedBranches(ZuulTestCase):
after=new_sha)
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
if expected_cat_jobs is not None:
@ -966,8 +969,8 @@ class TestGitlabUnprotectedBranches(ZuulTestCase):
self.fake_gitlab.emitEvent(pevent)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
if expect_reconfigure:
# New timestamp should be greater than the old timestamp

View File

@ -22,10 +22,13 @@ from testtools.matchers import MatchesRegex
import zuul.rpcclient
from zuul.lib import strings
from zuul.zk.layout import LayoutState
from tests.base import ZuulTestCase, simple_layout
from tests.base import ZuulWebFixture
EMPTY_LAYOUT_STATE = LayoutState("", "", 0)
class TestPagureDriver(ZuulTestCase):
config_file = 'zuul-pagure-driver.conf'
@ -215,12 +218,12 @@ class TestPagureDriver(ZuulTestCase):
newrev = repo.commit('refs/heads/stable-1.0').hexsha
event = self.fake_pagure.getGitBranchEvent(
'org/project', 'stable-1.0', 'creation', newrev)
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.fake_pagure.emitEvent(event)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# New timestamp should be greater than the old timestamp
self.assertLess(old, new)
self.assertEqual(1, len(self.history))
@ -248,8 +251,8 @@ class TestPagureDriver(ZuulTestCase):
def test_ref_updated_and_tenant_reconfigure(self):
self.waitUntilSettled()
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
zuul_yaml = [
{'job': {
@ -275,8 +278,8 @@ class TestPagureDriver(ZuulTestCase):
self.fake_pagure.emitEvent(event)
self.waitUntilSettled()
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
# New timestamp should be greater than the old timestamp
self.assertLess(old, new)

View File

@ -46,6 +46,9 @@ from tests.base import (
RecordingExecutorServer,
TestConnectionRegistry,
)
from zuul.zk.layout import LayoutState
EMPTY_LAYOUT_STATE = LayoutState("", "", 0)
class TestSchedulerSSL(SSLZuulTestCase):
@ -3655,9 +3658,9 @@ class TestScheduler(ZuulTestCase):
def test_live_reconfiguration_command_socket(self):
"Test that live reconfiguration via command socket works"
# record previous tenant reconfiguration time, which may not be set
old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
# record previous tenant reconfiguration state, which may not be set
old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
command_socket = self.scheds.first.config.get(
@ -3673,8 +3676,8 @@ class TestScheduler(ZuulTestCase):
while True:
if time.time() - start > 15:
raise Exception("Timeout waiting for full reconfiguration")
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
if old < new:
break
else:
@ -8794,10 +8797,10 @@ class TestSchedulerSmartReconfiguration(ZuulTestCase):
self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
# record previous tenant reconfiguration time, which may not be set
old_one = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
old_two = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-two', 0)
old_one = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
old_two = self.scheds.first.sched.tenant_layout_state.get(
'tenant-two', EMPTY_LAYOUT_STATE)
self.waitUntilSettled()
self.newTenantConfig('config/multi-tenant/main-reconfig.yaml')
@ -8813,8 +8816,8 @@ class TestSchedulerSmartReconfiguration(ZuulTestCase):
while True:
if time.time() - start > 15:
raise Exception("Timeout waiting for smart reconfiguration")
new_two = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-two', 0)
new_two = self.scheds.first.sched.tenant_layout_state.get(
'tenant-two', EMPTY_LAYOUT_STATE)
if old_two < new_two:
break
else:
@ -8822,8 +8825,8 @@ class TestSchedulerSmartReconfiguration(ZuulTestCase):
# Ensure that tenant-one has not been reconfigured
self.waitUntilSettled()
new_one = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new_one = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
self.assertEqual(old_one, new_one)
self.executor_server.hold_jobs_in_build = False
@ -8843,9 +8846,10 @@ class TestSchedulerSmartReconfiguration(ZuulTestCase):
self.assertEqual(expected_tenants,
self.scheds.first.sched.abide.tenants.keys())
self.assertIsNotNone(self.scheds.first.sched.tenant_last_reconfigured
.get('tenant-four'),
'Tenant tenant-four should exist now.')
self.assertIsNotNone(
self.scheds.first.sched.tenant_layout_state.get('tenant-four'),
'Tenant tenant-four should exist now.'
)
# Test that the new tenant-four actually works
D = self.fake_gerrit.addFakeChange('org/project4', 'master', 'D')
@ -8871,8 +8875,8 @@ class TestSchedulerSmartReconfiguration(ZuulTestCase):
class TestReconfigureBranch(ZuulTestCase):
def _setupTenantReconfigureTime(self):
self.old = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
self.old = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
def _createBranch(self):
self.create_branch('org/project1', 'stable')
@ -8889,8 +8893,8 @@ class TestReconfigureBranch(ZuulTestCase):
self.waitUntilSettled()
def _expectReconfigure(self, doReconfigure):
new = self.scheds.first.sched.tenant_last_reconfigured\
.get('tenant-one', 0)
new = self.scheds.first.sched.tenant_layout_state.get(
'tenant-one', EMPTY_LAYOUT_STATE)
if doReconfigure:
self.assertLess(self.old, new)
else:

View File

@ -23,6 +23,7 @@ from zuul.zk import ZooKeeperClient
from zuul.zk.config_cache import UnparsedConfigCache
from zuul.zk.exceptions import LockException
from zuul.zk.executor import ExecutorApi, BuildRequestEvent
from zuul.zk.layout import LayoutStateStore, LayoutState
from zuul.zk.locks import locked
from zuul.zk.nodepool import ZooKeeperNodepool
from zuul.zk.sharding import (
@ -662,3 +663,14 @@ class TestLocks(ZooKeeperBaseTestCase):
with locked(lock, blocking=False):
pass
self.assertFalse(lock.is_acquired)
class TestLayoutStore(ZooKeeperBaseTestCase):
def test_layout_state(self):
store = LayoutStateStore(self.zk_client)
state = LayoutState("tenant", "hostname", 0)
store["tenant"] = state
self.assertEqual(state, store["tenant"])
self.assertNotEqual(state.ltime, -1)
self.assertNotEqual(store["tenant"].ltime, -1)

View File

@ -82,6 +82,7 @@ from zuul.zk.event_queues import (
PipelineTriggerEventQueue,
TENANT_ROOT,
)
from zuul.zk.layout import LayoutState, LayoutStateStore
from zuul.zk.nodepool import ZooKeeperNodepool
COMMANDS = ['full-reconfigure', 'smart-reconfigure', 'stop', 'repl', 'norepl']
@ -191,6 +192,7 @@ class Scheduler(threading.Thread):
self.abide = Abide()
self.unparsed_abide = UnparsedAbideConfig()
self.tenant_layout_state = LayoutStateStore(self.zk_client)
if not testonly:
time_dir = self._get_time_database_dir()
@ -207,7 +209,6 @@ class Scheduler(threading.Thread):
else:
self.zuul_version = zuul_version.release_string
self.last_reconfigured = None
self.tenant_last_reconfigured = {}
self.use_relative_priority = False
if self.config.has_option('scheduler', 'relative_priority'):
if self.config.getboolean('scheduler', 'relative_priority'):
@ -1132,7 +1133,14 @@ class Scheduler(threading.Thread):
trigger.postConfig(pipeline)
for reporter in pipeline.actions:
reporter.postConfig()
self.tenant_last_reconfigured[tenant.name] = time.time()
layout_state = LayoutState(
tenant_name=tenant.name,
hostname=self.hostname,
last_reconfigured=int(time.time()),
)
self.tenant_layout_state[tenant.name] = layout_state
if self.statsd:
try:
for pipeline in tenant.layout.pipelines.values():
@ -1887,8 +1895,9 @@ class Scheduler(threading.Thread):
'length': len(queue),
}
if self.last_reconfigured:
data['last_reconfigured'] = self.last_reconfigured * 1000
layout_state = self.tenant_layout_state.get(tenant_name)
if layout_state:
data['last_reconfigured'] = layout_state.last_reconfigured * 1000
pipelines = []
data['pipelines'] = pipelines

133
zuul/zk/layout.py Normal file
View File

@ -0,0 +1,133 @@
# Copyright 2020 BMW Group
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import json
from collections.abc import MutableMapping
from functools import total_ordering
from uuid import uuid4
from kazoo.exceptions import NoNodeError
from zuul.zk import ZooKeeperBase
@total_ordering
class LayoutState:
"""Representation of a tenant's layout state.
The layout state holds information about a certain version of a
tenant's layout. It is used to coordinate reconfigurations across
multiple schedulers by comparing a local tenant layout state
against the current version in Zookeeper. In case it detects that
a local layout state is outdated, this scheduler is not allowed to
process this tenant (events, pipelines, ...) until the layout is
updated.
The important information of the layout state is the logical
timestamp (ltime) that is used to detect if the layout on a
scheduler needs to be updated. The ltime is the last modified
transaction ID (mzxid) of the corresponding Znode in Zookeeper.
The hostname of the scheduler creating the new layout state and the
timestamp of the last reconfiguration are only informational and
may aid in debugging.
"""
def __init__(self, tenant_name, hostname, last_reconfigured, uuid=None,
ltime=-1):
self.uuid = uuid or uuid4().hex
self.ltime = ltime
self.tenant_name = tenant_name
self.hostname = hostname
self.last_reconfigured = last_reconfigured
def toDict(self):
return {
"tenant_name": self.tenant_name,
"hostname": self.hostname,
"last_reconfigured": self.last_reconfigured,
"uuid": self.uuid,
}
@classmethod
def fromDict(cls, data):
return cls(
data["tenant_name"],
data["hostname"],
data["last_reconfigured"],
data.get("uuid"),
data.get("ltime", -1),
)
def __eq__(self, other):
if not isinstance(other, LayoutState):
return False
return self.uuid == other.uuid
def __gt__(self, other):
if not isinstance(other, LayoutState):
return False
return self.ltime > other.ltime
def __repr__(self):
return (
f"<{self.__class__.__name__} {self.tenant_name}: "
f"ltime={self.ltime}, "
f"hostname={self.hostname}, "
f"last_reconfigured={self.last_reconfigured}>"
)
class LayoutStateStore(ZooKeeperBase, MutableMapping):
layout_root = "/zuul/layout"
def __getitem__(self, tenant_name):
try:
data, zstat = self.kazoo_client.get(
f"{self.layout_root}/{tenant_name}")
except NoNodeError:
raise KeyError(tenant_name)
return LayoutState.fromDict({
"ltime": zstat.last_modified_transaction_id,
**json.loads(data)
})
def __setitem__(self, tenant_name, state):
path = f"{self.layout_root}/{tenant_name}"
self.kazoo_client.ensure_path(path)
data = json.dumps(state.toDict()).encode("utf-8")
zstat = self.kazoo_client.set(path, data)
# Set correct ltime of the layout in Zookeeper
state.ltime = zstat.last_modified_transaction_id
def __delitem__(self, tenant_name):
try:
self.kazoo_client.delete(f"{self.layout_root}/{tenant_name}")
except NoNodeError:
raise KeyError(tenant_name)
def __iter__(self):
try:
tenant_names = self.kazoo_client.get_children(self.layout_root)
except NoNodeError:
return
yield from tenant_names
def __len__(self):
zstat = self.kazoo_client.exists(self.layout_root)
if zstat is None:
return 0
return zstat.children_count