Don't lock pipelines during local layout update

In our Zuul deployment we can see that the local layout update takes
very long on a busy scheduler. We also observed that due to the locking
in the post-config hook, we might starve pipelines for the duration of
the local layout updates.

Since the pipeline state is already reset after the inital
reconfiguration we don't need to reset it again after a local layout
update. In case the pipeline's layout UUID is already the same as our
current one, we can skip that step.

Because we no longer restore the pipeline state from ZK in _postConfig,
we may exit the _reconfigureTenant method with an out of date copy of
the queue contents.  In particular, on startup our in-memory queue may
be empty.  Because of that, remove the statsd emission in that method
and rely more heavily on the pipeline manager emitting those stats.
The emission is moved to the prime method so that we still initialize
the stats to 0 when we add new tenants.

This change also removes the relative priority queues from the pipeline
state. The relative prio queues were never properly (de-)serialized and
shouldn't be stored in ZK at all.

This also corrects a use of locks in the prime method.  This case is
probably impossible to hit with the current code.  The lock which is
passed to the ZKContext in prime is only used if _reconfigureTenant
needs to modify objects in ZK for newly created tenants, but a newly
created tenant has no ZK objects that need modifying.  Because of that,
a test for this code is unfeasible, but let's correct the error anyway.
The lock_ctx is a generator, and the result of entering it as a context
manager is the actual lock that we need to pass to the ZKContext.

Change-Id: I7ad003a8fc24ab9be417a46287390675227e2db3
This commit is contained in:
Simon Westphahl
2022-02-08 16:42:11 -08:00
committed by James E. Blair
parent 7f5b49b3d3
commit 46e6a17692
5 changed files with 180 additions and 32 deletions
+125
View File
@@ -263,6 +263,131 @@ class TestScaleOutScheduler(ZuulTestCase):
holders = tenant.semaphore_handler.semaphoreHolders(semaphore)
self.assertEqual(len(holders), 0)
@simple_layout('layouts/two-projects-integrated.yaml')
def test_nodepool_relative_priority_check(self):
"Test that nodes are requested at the relative priority"
self.fake_nodepool.pause()
# Start a second scheduler that uses the existing layout
app = self.createScheduler()
app.start()
# Hold the lock on the first scheduler so that if any events
# happen, they are processed by the second scheduler.
with self.scheds.first.sched.run_handler_lock:
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled(matcher=[app])
B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
self.waitUntilSettled(matcher=[app])
C = self.fake_gerrit.addFakeChange('org/project1', 'master', 'C')
self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
self.waitUntilSettled(matcher=[app])
D = self.fake_gerrit.addFakeChange('org/project2', 'master', 'D')
self.fake_gerrit.addEvent(D.getPatchsetCreatedEvent(1))
self.waitUntilSettled(matcher=[app])
reqs = self.fake_nodepool.getNodeRequests()
# The requests come back sorted by priority.
# Change A, first change for project, high relative priority.
self.assertEqual(reqs[0]['_oid'], '200-0000000000')
self.assertEqual(reqs[0]['relative_priority'], 0)
# Change C, first change for project1, high relative priority.
self.assertEqual(reqs[1]['_oid'], '200-0000000002')
self.assertEqual(reqs[1]['relative_priority'], 0)
# Change B, second change for project, lower relative priority.
self.assertEqual(reqs[2]['_oid'], '200-0000000001')
self.assertEqual(reqs[2]['relative_priority'], 1)
# Change D, first change for project2 shared with project1,
# lower relative priority than project1.
self.assertEqual(reqs[3]['_oid'], '200-0000000003')
self.assertEqual(reqs[3]['relative_priority'], 1)
# Fulfill only the first request
self.fake_nodepool.fulfillRequest(reqs[0])
for x in iterate_timeout(30, 'fulfill request'):
reqs = list(self.scheds.first.sched.nodepool.getNodeRequests())
if len(reqs) < 4:
break
self.waitUntilSettled(matcher=[app])
reqs = self.fake_nodepool.getNodeRequests()
# Change B, now first change for project, equal priority.
self.assertEqual(reqs[0]['_oid'], '200-0000000001')
self.assertEqual(reqs[0]['relative_priority'], 0)
# Change C, now first change for project1, equal priority.
self.assertEqual(reqs[1]['_oid'], '200-0000000002')
self.assertEqual(reqs[1]['relative_priority'], 0)
# Change D, first change for project2 shared with project1,
# still lower relative priority than project1.
self.assertEqual(reqs[2]['_oid'], '200-0000000003')
self.assertEqual(reqs[2]['relative_priority'], 1)
self.fake_nodepool.unpause()
self.waitUntilSettled()
@simple_layout('layouts/two-projects-integrated.yaml')
def test_nodepool_relative_priority_gate(self):
"Test that nodes are requested at the relative priority"
self.fake_nodepool.pause()
# Start a second scheduler that uses the existing layout
app = self.createScheduler()
app.start()
# Hold the lock on the first scheduler so that if any events
# happen, they are processed by the second scheduler.
with self.scheds.first.sched.run_handler_lock:
A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
A.addApproval('Code-Review', 2)
self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
self.waitUntilSettled(matcher=[app])
B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
B.addApproval('Code-Review', 2)
self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
self.waitUntilSettled(matcher=[app])
# project does not share a queue with project1 and project2.
C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
C.addApproval('Code-Review', 2)
self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
self.waitUntilSettled(matcher=[app])
reqs = self.fake_nodepool.getNodeRequests()
# The requests come back sorted by priority.
# Change A, first change for shared queue, high relative
# priority.
self.assertEqual(reqs[0]['_oid'], '100-0000000000')
self.assertEqual(reqs[0]['relative_priority'], 0)
# Change C, first change for independent project, high
# relative priority.
self.assertEqual(reqs[1]['_oid'], '100-0000000002')
self.assertEqual(reqs[1]['relative_priority'], 0)
# Change B, second change for shared queue, lower relative
# priority.
self.assertEqual(reqs[2]['_oid'], '100-0000000001')
self.assertEqual(reqs[2]['relative_priority'], 1)
self.fake_nodepool.unpause()
self.waitUntilSettled()
class TestSOSCircularDependencies(ZuulTestCase):
# Those tests are testing specific interactions between multiple
+19 -8
View File
@@ -90,25 +90,37 @@ class PipelineManager(metaclass=ABCMeta):
self.current_context = None
def _postConfig(self, layout):
# All pipelines support shared queues for setting
# relative_priority; only the dependent pipeline uses them for
# pipeline queing.
# If our layout UUID already matches the UUID in ZK, we don't
# need to make any changes in ZK. But we do still need to
# update our local object pointers. Note that our local queue
# state may still be out of date after this because we skip
# the refresh.
self.buildChangeQueues(layout)
ctx = self.sched.createZKContext(None, self.log)
with self.currentContext(ctx):
if layout.uuid == PipelineState.peekLayoutUUID(self.pipeline):
self.pipeline.state = PipelineState()
self.pipeline.state._set(pipeline=self.pipeline)
self.pipeline.change_list = PipelineChangeList.create(
self.pipeline)
return
with pipeline_lock(
self.sched.zk_client, self.pipeline.tenant.name, self.pipeline.name
) as lock:
ctx = self.sched.createZKContext(lock, self.log)
with self.currentContext(ctx):
# Since the layout UUID is new, this will move queues
# to "old_queues" and refresh the pipeline state as a
# side effect.
self.pipeline.state = PipelineState.resetOrCreate(
self.pipeline, layout.uuid)
self.pipeline.change_list = PipelineChangeList.create(
self.pipeline)
self.buildChangeQueues(layout)
def buildChangeQueues(self, layout):
self.log.debug("Building relative_priority queues")
# Note: change_queues is serialized to ZK, so mutate a copy
# and then update the attribute when we finish.
change_queues = self.pipeline.relative_priority_queues.copy()
change_queues = self.pipeline.relative_priority_queues
tenant = self.pipeline.tenant
layout_project_configs = layout.project_configs
@@ -145,7 +157,6 @@ class PipelineManager(metaclass=ABCMeta):
change_queue.append(project)
self.log.debug("Added project %s to queue: %s" %
(project, queue_name))
self.pipeline.setRelativePriorityQueues(change_queues)
def getSubmitAllowNeeds(self):
# Get a list of code review labels that are allowed to be
-3
View File
@@ -21,9 +21,6 @@ class IndependentPipelineManager(PipelineManager):
changes_merge = False
type = 'independent'
def _postConfig(self, layout):
super(IndependentPipelineManager, self)._postConfig(layout)
def getChangeQueue(self, change, event, existing=None):
log = get_annotated_logger(self.log, event)
+18 -5
View File
@@ -20,6 +20,7 @@ import json
import hashlib
import logging
import os
import zlib
from functools import total_ordering
import re2
@@ -439,6 +440,7 @@ class Pipeline(object):
self.dequeue_on_new_patchset = True
self.ignore_dependencies = False
self.manager = None
self.relative_priority_queues = {}
self.precedence = PRECEDENCE_NORMAL
self.supercedes = []
self.triggers = []
@@ -464,10 +466,6 @@ class Pipeline(object):
def queues(self):
return self.state.queues
@property
def relative_priority_queues(self):
return self.state.relative_priority_queues
@property
def actions(self):
return (
@@ -595,7 +593,6 @@ class PipelineState(zkobject.ZKObject):
state=Pipeline.STATE_NORMAL,
queues=[],
old_queues=[],
relative_priority_queues={},
consecutive_failures=0,
disabled=False,
pipeline=None,
@@ -612,6 +609,22 @@ class PipelineState(zkobject.ZKObject):
obj._load(context, path=path)
return obj
@classmethod
def peekLayoutUUID(cls, pipeline):
ctx = pipeline.manager.current_context
try:
path = cls.pipelinePath(pipeline)
compressed_data, zstat = ctx.client.get(path)
try:
raw = zlib.decompress(compressed_data)
except zlib.error:
# Fallback for old, uncompressed data
raw = compressed_data
data = json.loads(raw.decode("utf8"))
return data["layout_uuid"]
except NoNodeError:
return None
@classmethod
def resetOrCreate(cls, pipeline, layout_uuid):
ctx = pipeline.manager.current_context
+18 -16
View File
@@ -790,6 +790,20 @@ class Scheduler(threading.Thread):
self.repl.stop()
self.repl = None
def _reportInitialStats(self, tenant):
if not self.statsd:
return
try:
for pipeline in tenant.layout.pipelines.values():
# stats.gauges.zuul.tenant.<tenant>.pipeline.
# <pipeline>.current_changes
key = 'zuul.tenant.%s.pipeline.%s' % (
tenant.name, pipeline.name)
self.statsd.gauge(key + '.current_changes', 0)
except Exception:
self.log.exception("Exception reporting initial "
"pipeline stats:")
def prime(self, config):
self.log.info("Priming scheduler config")
start = time.monotonic()
@@ -814,13 +828,13 @@ class Scheduler(threading.Thread):
# In case we don't have a cached layout state we need to
# acquire the write lock since we load a new tenant.
if layout_state is None:
tlock = tenant_write_lock(self.zk_client, tenant_name)
lock_ctx = tenant_write_lock(self.zk_client, tenant_name)
else:
tlock = tenant_read_lock(self.zk_client, tenant_name)
lock_ctx = tenant_read_lock(self.zk_client, tenant_name)
# Consider all caches valid (min. ltime -1)
min_ltimes = defaultdict(lambda: defaultdict(lambda: -1))
with tlock:
with lock_ctx as tlock:
# Refresh the layout state now that we are holding the lock
# and we can be sure it won't be changed concurrently.
layout_state = self.tenant_layout_state.get(tenant_name)
@@ -845,6 +859,7 @@ class Scheduler(threading.Thread):
# Reconfigure only tenants w/o an existing layout state
ctx = self.createZKContext(tlock, self.log)
self._reconfigureTenant(ctx, tenant)
self._reportInitialStats(tenant)
else:
self.local_layout_state[tenant_name] = layout_state
self.connections.reconfigureDrivers(tenant)
@@ -1481,19 +1496,6 @@ class Scheduler(threading.Thread):
self.local_layout_state[tenant.name] = layout_state
self.tenant_layout_state[tenant.name] = layout_state
if self.statsd:
try:
for pipeline in tenant.layout.pipelines.values():
items = len([x for x in pipeline.getAllItems() if x.live])
# stats.gauges.zuul.tenant.<tenant>.pipeline.
# <pipeline>.current_changes
key = 'zuul.tenant.%s.pipeline.%s' % (
tenant.name, pipeline.name)
self.statsd.gauge(key + '.current_changes', items)
except Exception:
self.log.exception("Exception reporting initial "
"pipeline stats:")
def _reconfigureDeleteTenant(self, context, tenant):
# Called when a tenant is deleted during reconfiguration
self.log.info("Removing tenant %s during reconfiguration" %