Skip to content

Commit

Permalink
Merge branch 'debounce'
Browse files Browse the repository at this point in the history
  • Loading branch information
djmitche committed Apr 28, 2014
2 parents 7f4101c + fb3838b commit f9f695a
Show file tree
Hide file tree
Showing 3 changed files with 394 additions and 0 deletions.
241 changes: 241 additions & 0 deletions master/buildbot/test/unit/test_util_debounce.py
@@ -0,0 +1,241 @@
# This file is part of Buildbot. Buildbot is free software: you can
# redistribute it and/or modify it under the terms of the GNU General Public
# License as published by the Free Software Foundation, version 2.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# Copyright Buildbot Team Members

from buildbot.util import debounce
from twisted.internet import defer
from twisted.internet import task
from twisted.python import failure
from twisted.python import log
from twisted.trial import unittest


class DebouncedClass(object):

def __init__(self):
self.callDeferred = None
self.calls = 0
self.expCalls = 0
self.stopDeferreds = []

@debounce.method(wait=4.0)
def maybe(self):
assert not self.callDeferred
self.calls += 1
log.msg('debounced function called')
self.callDeferred = defer.Deferred()

@self.callDeferred.addBoth
def unset(x):
log.msg('debounced function complete')
self.callDeferred = None
return x
return self.callDeferred


class DebounceTest(unittest.TestCase):

def setUp(self):
self.clock = task.Clock()

def scenario(self, events):
dbs = dict((k, DebouncedClass())
for k in set([n for n, _, _ in events]))
for db in dbs.values():
db.maybe._reactor = self.clock
while events:
n, t, e = events.pop(0)
db = dbs[n]
log.msg('time=%f, event=%s' % (t, e))
if t > self.clock.seconds():
self.clock.advance(t - self.clock.seconds())
if e == 'maybe':
db.maybe()
elif e == 'called':
db.expCalls += 1
elif e == 'complete':
db.callDeferred.callback(None)
elif e == 'fail':
db.callDeferred.errback(failure.Failure(RuntimeError()))
elif e == 'failure_logged':
self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1)
elif e == 'check':
pass # just check the expCalls
elif e == 'start':
db.maybe.start()
elif e == 'stop':
db.stopDeferreds.append(db.maybe.stop())
elif e == 'stopNotComplete':
self.assertFalse(db.stopDeferreds[-1].called)
elif e == 'stopComplete':
self.assertTrue(db.stopDeferreds[-1].called)
db.stopDeferreds.pop()
else:
self.fail("unknown scenario event %s" % e)
for db in dbs.values():
self.assertEqual(db.calls, db.expCalls)

def test_called_once(self):
"""The debounced method is called only after 4 seconds"""
self.scenario([
(1, 0.0, 'maybe'),
(1, 2.0, 'check'),
(1, 4.0, 'called'),
(1, 5.0, 'check'),
(1, 6.0, 'complete'),
(1, 7.0, 'check')
])

def test_coalesce_calls(self):
"""Multiple calls are ecoalesced during 4 seconds, but the function
runs 4 seconds after the first call."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 1.0, 'maybe'),
(1, 2.0, 'maybe'),
(1, 3.0, 'maybe'),
(1, 4.0, 'called'),
(1, 5.0, 'check'),
(1, 6.0, 'complete'),
(1, 7.0, 'check'),
])

def test_second_call_during_first(self):
"""If the debounced method is called after an execution has begun, then
a second execution will take place 4 seconds after the execution
finishes, with intervening calls coalesced."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 4.0, 'called'),
(1, 5.0, 'maybe'),
(1, 6.0, 'complete'),
(1, 7.0, 'maybe'),
(1, 9.0, 'maybe'),
(1, 10.0, 'called'),
(1, 11.0, 'check'),
])

def test_failure_logged(self):
"""If the debounced method fails, the error is logged, but otherwise it
behaves as if it had succeeded."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 4.0, 'called'),
(1, 5.0, 'maybe'),
(1, 6.0, 'fail'),
(1, 6.0, 'failure_logged'),
(1, 10.0, 'called'),
(1, 11.0, 'check'),
])

def test_instance_independence(self):
"""The timers for two instances are independent."""
self.scenario([
(1, 0.0, 'maybe'),
(2, 2.0, 'maybe'),
(1, 4.0, 'called'),
(2, 6.0, 'called'),
(1, 6.0, 'complete'),
(2, 6.0, 'complete'),
(1, 7.0, 'check'),
])

def test_start_when_started(self):
"""Calling meth.start when already started has no effect"""
self.scenario([
(1, 0.0, 'start'),
(1, 1.0, 'start'),
])

def test_stop_while_idle(self):
"""If the debounced method is stopped while idle, subsequent calls do
nothing."""
self.scenario([
(1, 0.0, 'stop'),
(1, 0.0, 'stopComplete'),
(1, 1.0, 'maybe'),
(1, 6.0, 'check'), # not called
])

def test_stop_while_waiting(self):
"""If the debounced method is stopped while waiting, the waiting call
never occurs, stop returns immediately, and subsequent calls do
nothing."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 2.0, 'stop'),
(1, 2.0, 'stopComplete'),
(1, 3.0, 'maybe'),
(1, 8.0, 'check'), # not called
])

def test_stop_while_running(self):
"""If the debounced method is stopped while running, the running call
completes, stop returns only after the call completes, and subsequent
calls do nothing."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 4.0, 'called'),
(1, 5.0, 'stop'),
(1, 5.0, 'stopNotComplete'),
(1, 6.0, 'complete'),
(1, 6.0, 'stopComplete'),
(1, 6.0, 'maybe'),
(1, 10.0, 'check'), # not called
])

def test_multiple_stops(self):
"""Multiple stop calls will return individually when the method
completes."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 4.0, 'called'),
(1, 5.0, 'stop'),
(1, 5.0, 'stop'),
(1, 5.0, 'stopNotComplete'),
(1, 6.0, 'complete'),
(1, 6.0, 'stopComplete'),
(1, 6.0, 'stopComplete'),
(1, 6.0, 'maybe'),
(1, 10.0, 'check'), # not called
])

def test_stop_while_running_queued(self):
"""If the debounced method is stopped while running with another call
queued, the running call completes, stop returns only after the call
completes, the queued call never occurs, and subsequent calls do
nothing."""
self.scenario([
(1, 0.0, 'maybe'),
(1, 4.0, 'called'),
(1, 4.5, 'maybe'),
(1, 5.0, 'stop'),
(1, 5.0, 'stopNotComplete'),
(1, 6.0, 'complete'),
(1, 6.0, 'stopComplete'),
(1, 6.0, 'maybe'),
(1, 10.0, 'check'), # not called
])

def test_start_after_stop(self):
"""After a stop and subsequent start, a call to the debounced method
causes an invocation 4 seconds later."""
self.scenario([
(1, 0.0, 'stop'),
(1, 1.0, 'maybe'),
(1, 2.0, 'start'),
(1, 2.0, 'maybe'),
(1, 5.0, 'check'), # not called
(1, 6.0, 'called'),
])
113 changes: 113 additions & 0 deletions master/buildbot/util/debounce.py
@@ -0,0 +1,113 @@
# This file is part of Buildbot. Buildbot is free software: you can
# redistribute it and/or modify it under the terms of the GNU General Public
# License as published by the Free Software Foundation, version 2.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# Copyright Buildbot Team Members

import functools

from twisted.internet import defer
from twisted.internet import reactor
from twisted.python import log

# debounce phases
PH_IDLE = 0
PH_WAITING = 1
PH_RUNNING = 2
PH_RUNNING_QUEUED = 3


class Debouncer(object):
__slots__ = ['phase', 'timer', 'wait', 'function', 'stopped',
'completeDeferreds', '_reactor']

def __init__(self, wait, function):
# time to wait
self.wait = wait
# zero-argument callable to invoke
self.function = function
# current phase
self.phase = PH_IDLE
# Twisted timer for waiting
self.timer = None
# true if this instance is stopped
self.stopped = False
# deferreds to fire when the call is complete
self.completeDeferreds = None
# for tests
self._reactor = reactor

def __call__(self):
log.msg('invoked, stop=' + str(self.stopped))
if self.stopped:
return
phase = self.phase
if phase == PH_IDLE:
self.timer = self._reactor.callLater(self.wait, self.invoke)
self.phase = PH_WAITING
elif phase == PH_RUNNING:
self.phase = PH_RUNNING_QUEUED
else: # phase == PH_WAITING or phase == PH_RUNNING_QUEUED:
pass

def invoke(self):
log.msg('invoke')
self.phase = PH_RUNNING
self.completeDeferreds = []
d = defer.maybeDeferred(self.function)
d.addErrback(log.err, 'from debounced function:')

@d.addCallback
def retry(_):
queued = self.phase == PH_RUNNING_QUEUED
self.phase = PH_IDLE
while self.completeDeferreds:
self.completeDeferreds.pop(0).callback(None)
if queued:
self.__call__()

def start(self):
self.stopped = False

def stop(self):
self.stopped = True
if self.phase == PH_WAITING:
self.timer.cancel()
self.phase = PH_IDLE
elif self.phase in (PH_RUNNING, PH_RUNNING_QUEUED):
d = defer.Deferred()
self.completeDeferreds.append(d)
return d
return defer.succeed(None)


class _Descriptor(object):

def __init__(self, fn, wait, attrName):
self.fn = fn
self.wait = wait
self.attrName = attrName

def __get__(self, instance, cls):
try:
db = getattr(instance, self.attrName)
except AttributeError:
db = Debouncer(self.wait, functools.partial(self.fn, instance))
setattr(instance, self.attrName, db)
return db


def method(wait):
def wrap(fn):
stateName = "__debounce_" + fn.__name__ + "__"
return _Descriptor(fn, wait, stateName)
return wrap

0 comments on commit f9f695a

Please sign in to comment.