Skip to content

Commit 19b2cbd

Browse files
committed
Simplify debouncing executor
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
1 parent 5b53c34 commit 19b2cbd

File tree

3 files changed

+133
-83
lines changed

3 files changed

+133
-83
lines changed

src/main/java/nextflow/lsp/services/LanguageService.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,7 @@
8888
*/
8989
public abstract class LanguageService {
9090

91-
private static final int DEBOUNCE_MILLIS = 1_000;
92-
93-
private static final Object DEBOUNCE_KEY = new Object();
91+
private static final long DEBOUNCE_MILLIS = 1_000;
9492

9593
private static Logger log = Logger.getInstance();
9694

@@ -101,7 +99,7 @@ public abstract class LanguageService {
10199
private DebouncingExecutor updateExecutor;
102100

103101
public LanguageService() {
104-
this.updateExecutor = new DebouncingExecutor(DEBOUNCE_MILLIS, (key) -> update());
102+
this.updateExecutor = new DebouncingExecutor(DEBOUNCE_MILLIS, this::update);
105103
}
106104

107105
public abstract boolean matchesFile(String uri);
@@ -295,11 +293,11 @@ public List<? extends WorkspaceSymbol> symbol(WorkspaceSymbolParams params) {
295293

296294
protected void updateLater() {
297295
awaitingUpdate = true;
298-
updateExecutor.submit(DEBOUNCE_KEY);
296+
updateExecutor.executeLater();
299297
}
300298

301299
protected void updateNow() {
302-
updateExecutor.executeNow(DEBOUNCE_KEY);
300+
updateExecutor.executeNow();
303301
}
304302

305303
protected void awaitUpdate() {

src/main/java/nextflow/lsp/util/DebouncingExecutor.java

Lines changed: 37 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,104 +15,64 @@
1515
*/
1616
package nextflow.lsp.util;
1717

18-
import java.util.concurrent.ConcurrentHashMap;
1918
import java.util.concurrent.Executors;
2019
import java.util.concurrent.ScheduledExecutorService;
20+
import java.util.concurrent.ScheduledFuture;
2121
import java.util.concurrent.TimeUnit;
22-
import java.util.function.Consumer;
22+
import java.util.concurrent.atomic.AtomicReference;
2323

2424
/**
2525
* Executor service that debounces incoming tasks, so
2626
* that a task is executed only after not being triggered
27-
* for a given time period.
28-
*
29-
* see: https://stackoverflow.com/questions/4742210/implementing-debounce-in-java/20978973
27+
* for a given delay.
3028
*
3129
* @author Ben Sherman <bentshermann@gmail.com>
3230
*/
33-
public class DebouncingExecutor<T> {
34-
private int delayMillis;
35-
private Consumer<T> action;
36-
private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
37-
private ConcurrentHashMap<T, DelayedTask> delayedTasks = new ConcurrentHashMap<>();
31+
public class DebouncingExecutor {
32+
private final long delayMillis;
33+
private final Runnable action;
34+
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
35+
private final AtomicReference<ScheduledFuture<?>> futureRef = new AtomicReference<>();
3836

39-
public DebouncingExecutor(int delayMillis, Consumer<T> action) {
37+
public DebouncingExecutor(long delayMillis, Runnable action) {
4038
this.delayMillis = delayMillis;
4139
this.action = action;
4240
}
4341

44-
public void submit(T key) {
45-
var newTask = new DelayedTask(key);
42+
/**
43+
* Schedule the action after the configured delay, cancelling
44+
* the currently scheduled task if present.
45+
*/
46+
public synchronized void executeLater() {
47+
cancelExisting();
4648

47-
// try until new task was added, or existing task was extended
48-
DelayedTask oldTask;
49-
do {
50-
oldTask = delayedTasks.putIfAbsent(key, newTask);
51-
if( oldTask == null )
52-
executor.schedule(newTask, delayMillis, TimeUnit.MILLISECONDS);
53-
} while( oldTask != null && !oldTask.extend() );
54-
}
49+
var future = scheduler.schedule(() -> {
50+
action.run();
51+
futureRef.set(null);
52+
}, delayMillis, TimeUnit.MILLISECONDS);
5553

56-
public void executeNow(T key) {
57-
var task = delayedTasks.get(key);
58-
if( task != null )
59-
task.cancel();
60-
action.accept(key);
54+
futureRef.set(future);
6155
}
6256

63-
public void shutdownNow() {
64-
executor.shutdownNow();
57+
/**
58+
* Execute the action immediately, cancelling the currently
59+
* scheduled task if present.
60+
*/
61+
public synchronized void executeNow() {
62+
cancelExisting();
63+
action.run();
6564
}
6665

67-
private class DelayedTask implements Runnable {
68-
private T key;
69-
private long dueTime;
70-
private Object lock = new Object();
71-
72-
public DelayedTask(T key) {
73-
this.key = key;
74-
extend();
75-
}
76-
77-
public boolean extend() {
78-
synchronized (lock) {
79-
if( dueTime < 0 )
80-
return false;
81-
dueTime = System.currentTimeMillis() + delayMillis;
82-
return true;
83-
}
84-
}
85-
86-
public void cancel() {
87-
synchronized (lock) {
88-
dueTime = -1;
89-
delayedTasks.remove(key);
90-
}
91-
}
92-
93-
public void run() {
94-
synchronized (lock) {
95-
var remaining = dueTime - System.currentTimeMillis();
96-
if( remaining > 0 ) {
97-
// re-schedule task
98-
executor.schedule(this, remaining, TimeUnit.MILLISECONDS);
99-
}
100-
else if( dueTime != -1 ) {
101-
// mark task as terminated and invoke callback
102-
dueTime = -1;
103-
try {
104-
action.accept(key);
105-
}
106-
catch( Exception e ) {
107-
System.err.println("exception while invoking debounce callback: " + e.toString());
108-
e.printStackTrace(System.err);
109-
}
110-
finally {
111-
delayedTasks.remove(key);
112-
}
113-
}
114-
}
115-
}
66+
private void cancelExisting() {
67+
var existing = futureRef.getAndSet(null);
68+
if( existing != null && !existing.isDone() )
69+
existing.cancel(false);
11670
}
11771

72+
/**
73+
* Call this method to shut down the executor when no longer needed.
74+
*/
75+
public void shutdown() {
76+
scheduler.shutdownNow();
77+
}
11878
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2024-2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.lsp.util
18+
19+
import java.util.concurrent.CountDownLatch
20+
import java.util.concurrent.TimeUnit
21+
22+
import spock.lang.Specification
23+
24+
/**
25+
*
26+
* @author Ben Sherman <bentshermann@gmail.com>
27+
*/
28+
class DebouncingExecutorSpec extends Specification {
29+
30+
def "debouncer delays and deduplicates rapid submissions"() {
31+
given:
32+
def counter = 0
33+
def latch = new CountDownLatch(1)
34+
def debouncer = new DebouncingExecutor(200, {
35+
counter++
36+
latch.countDown()
37+
})
38+
39+
when: "executeLater is called rapidly multiple times"
40+
debouncer.executeLater()
41+
Thread.sleep(50)
42+
debouncer.executeLater()
43+
Thread.sleep(50)
44+
debouncer.executeLater()
45+
46+
then: "action is only run once after the last delay"
47+
latch.await(1, TimeUnit.SECONDS)
48+
counter == 1
49+
50+
cleanup:
51+
debouncer.shutdown()
52+
}
53+
54+
def "executeNow runs the action immediately and cancels pending task"() {
55+
given:
56+
def executed = false
57+
def latch = new CountDownLatch(1)
58+
def debouncer = new DebouncingExecutor(300, {
59+
executed = true
60+
latch.countDown()
61+
})
62+
63+
when:
64+
debouncer.executeLater()
65+
Thread.sleep(100)
66+
debouncer.executeNow()
67+
68+
then: "action runs immediately"
69+
latch.await(500, TimeUnit.MILLISECONDS)
70+
executed
71+
72+
cleanup:
73+
debouncer.shutdown()
74+
}
75+
76+
def "multiple executeNow calls run the action each time"() {
77+
given:
78+
def executions = 0
79+
def debouncer = new DebouncingExecutor(100, { executions++ })
80+
81+
when:
82+
debouncer.executeNow()
83+
debouncer.executeNow()
84+
debouncer.executeNow()
85+
86+
then:
87+
executions == 3
88+
89+
cleanup:
90+
debouncer.shutdown()
91+
}
92+
}

0 commit comments

Comments
 (0)