Skip to content

Instantly share code, notes, and snippets.

@MustafaHaddara
Created May 15, 2020 10:56
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save MustafaHaddara/6da83cd54d6df393520558f77e1efe24 to your computer and use it in GitHub Desktop.
Save MustafaHaddara/6da83cd54d6df393520558f77e1efe24 to your computer and use it in GitHub Desktop.
Java Debouncer
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DebouncedRunnable implements Runnable {
private static final Logger LOGGER = LoggerFactory.getLogger(DebouncedRunnable.class);
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final Runnable operation;
private final String name;
private final long delayMillis;
// state
private long lastRunTime = -1;
private boolean isQueued = false;
/**
* Creates a thread-safe "debounced" version of the given Runnable. This means that when the client calls `run()`:
*
* - if it hasn't been called within the past `delayMillis` ms (and there isn't a call queued),
* then the wrapped Runnable gets called immediately
* - if there IS a recent call, then we check to see if one is queued
* - if there is a call queued, we drop the current call
* - if there is not, we queue up the current call to be called after `delayMillis` ms pass
*
* Full behaviour chart:
* | | `lastRunTime` is a long time ago | lastRunTime is recent |
* | | or `lastRunTime` = -1 | |
* |---------------------------------|----------------------------------|------------------------|
* | already have call queued | do nothing | do nothing |
* | do not already have call queued | run immediately | queue call to get run |
*
* Note that `Runnable` accepts no params, meaning each invocation should be interchangeable
* If we want to extend this mechanism to support calls with args, we will need to decide which params get used
* when we end up invoking the Runnable (the first set? the last?)
*/
public DebouncedRunnable(Runnable operation, String name, long delayMillis) {
this.operation = operation;
this.name = name;
this.delayMillis = delayMillis;
}
public synchronized void run() {
long currentTime = getCurrentTimeMillis();
if (isQueued) {
// we've already got a call queued, ignore this current one
LOGGER.debug("dropping {} because it is already queued", name);
} else if (shouldRunNow(currentTime)) {
// we've never called this before, call it now
lastRunTime = currentTime;
LOGGER.debug("calling {} immediately", name);
operation.run();
} else {
// we've called it recently, which suggests that we might have more of these incoming
// queue this up in to be run `delayMillis` milliseconds, and any incoming calls will get ignored
LOGGER.debug("queueing {} to be called in {} ms", name, delayMillis);
isQueued = true;
schedule(this::scheduledRun, delayMillis);
}
}
private synchronized void scheduledRun() {
LOGGER.debug("calling queued task {} after waiting {} ms", name, delayMillis);
lastRunTime = getCurrentTimeMillis();
isQueued = false;
operation.run();
}
/**
* Should run now if we've never run it before or we've run it more than `delayMillis` ms in the past
*/
private boolean shouldRunNow(long currentTime) {
return lastRunTime == -1 || lastRunTime + delayMillis < currentTime;
}
/**
* package-private for unit testing purposes
*/
void schedule(Runnable call, long delayMillis) {
scheduler.schedule(call, delayMillis, TimeUnit.MILLISECONDS);
}
/**
* package-private for unit testing purposes
*/
long getCurrentTimeMillis() {
return System.currentTimeMillis();
}
}
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
public class DebouncedRunnableTest {
private final AtomicInteger callCount = new AtomicInteger(0);
private final DebouncedRunnable debouncedIncrement = Mockito.spy(new DebouncedRunnable(callCount::incrementAndGet, "mock", 10));
private final List<Runnable> queued = new ArrayList<>();
@Before
public void setup() {
// capture all of the Runnables that would get queued
doAnswer(invocation -> {
queued.add(invocation.getArgument(0));
return null;
}).when(debouncedIncrement).schedule(any(), anyLong());
}
@Test
public void testDebounceQueueing() {
// set current time
setCurrentTime(0);
debouncedIncrement.run(); // time = 0, this is the first, it should be called right away
debouncedIncrement.run(); // time = 0, should be queued
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
// advance time, call the queued Runnable, verify it did what it's supposed to
setCurrentTime(15);
queued.remove(0).run();
assertEquals(2, callCount.get());
debouncedIncrement.run(); // time = 15, last call time was t=10, this one should get queued
// it was queued
assertEquals(2, callCount.get());
assertEquals(1, queued.size());
}
@Test
public void testDebounceBigDelay() {
// init time
setCurrentTime(0);
debouncedIncrement.run(); // time = 0, this is the first, it should be called right away
// advance time far
setCurrentTime(50);
debouncedIncrement.run(); // time = 50, last call time was t=0, this one should get called right away
// it was called right away, not queued
assertEquals(2, callCount.get());
assertEquals(0, queued.size());
}
@Test
public void testDebounceDrop() {
// set current time
setCurrentTime(0);
debouncedIncrement.run(); // time = 0, this is the first, it should be called right away
debouncedIncrement.run(); // time = 0, should be queued
debouncedIncrement.run(); // time = 0, should be dropped!
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
}
@Test
public void testDebounceDelayedDrop() {
// set current time
setCurrentTime(0);
debouncedIncrement.run(); // time = 0, this is the first, it should be called right away
debouncedIncrement.run(); // time = 0, should be queued
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
// advance time, but DO NOT call the queued Runnable, verify it did what it's supposed to
setCurrentTime(11);
debouncedIncrement.run(); // time = 11, last call time was t=0, but we have a call already queued, this one should get ignored
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
}
private void setCurrentTime(long currentTime) {
doReturn(currentTime).when(debouncedIncrement).getCurrentTimeMillis();
}
}
private static final Logger LOGGER = LoggerFactory.getLogger(DebouncedRunnable.class);
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final Runnable operation;
private final String name;
private final long delayMillis;
// state
private long lastRunTime = -1;
private boolean isQueued = false;
public DebouncedRunnable(Runnable operation, String name, long delayMillis) {
this.operation = operation;
this.name = name;
this.delayMillis = delayMillis;
}
private final AtomicInteger callCount = new AtomicInteger(0);
private final DebouncedRunnable debouncedIncrement = Mockito.spy(new DebouncedRunnable(callCount::incrementAndGet, "mock", 10));
private final List<Runnable> queued = new ArrayList<>();
public class MyService {
private static final long VOLUNTEER_DELAY_MILLIS = 1000L;
private final Runnable DEBOUNCED_VOLUNTEER;
public MyService() {
this.DEBOUNCED_VOLUNTEER = new DebouncedRunnable(
this::volunteer_yesIKnowWhatImDoing,
"VOLUNTEER",
VOLUNTEER_DELAY_MILLIS
);
}
public void volunteer() {
this.DEBOUNCED_VOLUNTEER.run();
}
@Deprecated // DO NOT CALL THIS YOURSELF! YOU ALMOST CERTAINLY WANT `volunteer()`
private void volunteer_yesIKnowWhatImDoing() {
// same API request as volunteer() above
}
}
public class MyService {
public void volunteer() {
// make API request
}
}
private boolean shouldRunNow(long currentTime) {
return lastRunTime == -1 || lastRunTime + delayMillis < currentTime;
}
void schedule(Runnable call, long delayMillis) {
scheduler.schedule(call, delayMillis, TimeUnit.MILLISECONDS);
}
long getCurrentTimeMillis() {
return System.currentTimeMillis();
}
public synchronized void run() {
long currentTime = getCurrentTimeMillis();
if (isQueued) {
// we've already got a call queued, ignore this current one
LOGGER.debug("dropping {} because it is already queued", name);
} else if (shouldRunNow(currentTime)) {
// we've never called this before, call it now
lastRunTime = currentTime;
LOGGER.debug("calling {} immediately", name);
operation.run();
} else {
// we've called it recently, which suggests that we might have more of these incoming
// queue this up in to be run `delayMillis` milliseconds, and any incoming calls will get ignored
LOGGER.debug("queueing {} to be called in {} ms", name, delayMillis);
isQueued = true;
schedule(this::scheduledRun, delayMillis);
}
}
private synchronized void scheduledRun() {
LOGGER.debug("calling queued task {} after waiting {} ms", name, delayMillis);
lastRunTime = getCurrentTimeMillis();
isQueued = false;
operation.run();
}
private void setCurrentTime(long currentTime) {
doReturn(currentTime).when(debouncedIncrement).getCurrentTimeMillis();
}
@Before
public void setup() {
// capture all of the Runnables that would get queued
doAnswer(invocation -> {
queued.add(invocation.getArgument(0));
return null;
}).when(debouncedIncrement).schedule(any(), anyLong());
}
@Test
public void testDebounceDelayedDrop() {
// set current time
setCurrentTime(0);
debouncedIncrement.run(); // time = 0, this is the first, it should be called right away
debouncedIncrement.run(); // time = 0, should be queued
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
// advance time, but DO NOT call the queued Runnable, verify it did what it's supposed to
setCurrentTime(11);
debouncedIncrement.run(); // time = 11, last call time was t=0, but we have a call already queued, this one should get ignored
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
}
@Test
public void testDebounceQueueing() {
// set current time
setCurrentTime(0);
debouncedIncrement.run(); // time = 0, this is the first, it should be called right away
debouncedIncrement.run(); // time = 0, should be queued
// expect only one call + one queued
assertEquals(1, callCount.get());
assertEquals(1, queued.size());
// advance time, call the queued Runnable, verify it did what it's supposed to
setCurrentTime(15);
queued.remove(0).run();
assertEquals(2, callCount.get());
debouncedIncrement.run(); // time = 15, last call time was t=15, this one should get queued
// it was queued
assertEquals(2, callCount.get());
assertEquals(1, queued.size());
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment