Skip to content

Commit 111c161

Browse files
authored
Merge pull request #20 from SWAT-engineering/improved-overflow-support-main
Improved overflow support
2 parents fb56f1e + 3afc76f commit 111c161

23 files changed

+1754
-419
lines changed

.github/workflows/build.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
pull_request:
99
branches:
1010
- main
11+
- improved-overflow-support-main
1112

1213
jobs:
1314
test:

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ a java file watcher that works across platforms and supports recursion, single f
77
Features:
88

99
- monitor a single file (or directory) for changes
10-
- monitor a directory for changes to it's direct descendants
11-
- monitor a directory for changes for all it's descendants (aka recursive directory watch)
10+
- monitor a directory for changes to its direct descendants
11+
- monitor a directory for changes for all its descendants (aka recursive directory watch)
1212
- edge cases dealt with:
13-
- in case of overflow we will still generate events for new descendants
1413
- recursive watches will also continue in new directories
1514
- multiple watches for the same directory are merged to avoid overloading the kernel
1615
- events are processed in a configurable worker pool
16+
- when an overflow happens, automatically approximate the events that were
17+
missed using a configurable approximation policy
1718

1819
Planned features:
1920

@@ -41,6 +42,7 @@ Start using java-watch:
4142
var directory = Path.of("tmp", "test-dir");
4243
var watcherSetup = Watcher.watch(directory, WatchScope.PATH_AND_CHILDREN)
4344
.withExecutor(Executors.newCachedThreadPool()) // optionally configure a custom thread pool
45+
.onOverflow(Approximation.DIFF) // optionally configure a handler for overflows
4446
.on(watchEvent -> {
4547
System.err.println(watchEvent);
4648
});

src/main/java/engineering/swat/watch/ActiveWatch.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,22 @@
2727
package engineering.swat.watch;
2828

2929
import java.io.Closeable;
30+
import java.nio.file.Path;
3031

3132
/**
32-
* <p>Marker interface for an active watch, in the future might get properties you can inspect.</p>
33+
* <p>Marker interface for an active watch, in the future might get more properties you can inspect.</p>
3334
*
34-
* <p>For now, make sure to close the watch when not interested in new events</p>
35+
* <p>For now, make sure to close the watch when not interested in new events.</p>
3536
*/
3637
public interface ActiveWatch extends Closeable {
3738

39+
/**
40+
* Gets the path watched by this watch.
41+
*/
42+
Path getPath();
43+
44+
/**
45+
* Gets the scope of this watch.
46+
*/
47+
WatchScope getScope();
3848
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* BSD 2-Clause License
3+
*
4+
* Copyright (c) 2023, Swat.engineering
5+
*
6+
* Redistribution and use in source and binary forms, with or without
7+
* modification, are permitted provided that the following conditions are met:
8+
*
9+
* 1. Redistributions of source code must retain the above copyright notice, this
10+
* list of conditions and the following disclaimer.
11+
*
12+
* 2. Redistributions in binary form must reproduce the above copyright notice,
13+
* this list of conditions and the following disclaimer in the documentation
14+
* and/or other materials provided with the distribution.
15+
*
16+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26+
*/
27+
package engineering.swat.watch;
28+
29+
/**
30+
* Constants to indicate for which regular files/directories in the scope of the
31+
* watch an <i>approximation</i> of synthetic events (of kinds
32+
* {@link WatchEvent.Kind#CREATED}, {@link WatchEvent.Kind#MODIFIED}, and/or
33+
* {@link WatchEvent.Kind#DELETED}) should be issued when an overflow event
34+
* happens. These synthetic events, as well as the overflow event itself, are
35+
* subsequently passed to the user-defined event handler of the watch.
36+
* Typically, the user-defined event handler can ignore the original overflow
37+
* event (i.e., handling the synthetic events is sufficient to address the
38+
* overflow issue), but it doesn't have to (e.g., it may carry out additional
39+
* overflow bookkeeping).
40+
*/
41+
public enum Approximation {
42+
43+
/**
44+
* Synthetic events are issued for <b>no regular files/directories</b> in
45+
* the scope of the watch. Thus, the user-defined event handler is fully
46+
* responsible to handle overflow events.
47+
*/
48+
NONE,
49+
50+
/**
51+
* <p>
52+
* Synthetic events of kinds {@link WatchEvent.Kind#CREATED} and
53+
* {@link WatchEvent.Kind#MODIFIED}, but not
54+
* {@link WatchEvent.Kind#DELETED}, are issued for all regular
55+
* files/directories in the scope of the watch. Specifically, when an
56+
* overflow event happens:
57+
*
58+
* <ul>
59+
* <li>CREATED events are issued for all regular files/directories
60+
* (overapproximation).
61+
* <li>MODIFIED events are issued for all non-empty, regular files
62+
* (overapproximation) but for no directories (underapproximation).
63+
* <li>DELETED events are issued for no regular files/directories
64+
* (underapproximation).
65+
* </ul>
66+
*
67+
* <p>
68+
* This approach is relatively cheap in terms of memory usage (cf.
69+
* {@link #DIFF}), but it results in a large over/underapproximation of the
70+
* actual events (cf. DIFF).
71+
*/
72+
ALL,
73+
74+
75+
/**
76+
* <p>
77+
* Synthetic events of kinds {@link WatchEvent.Kind#CREATED},
78+
* {@link WatchEvent.Kind#MODIFIED}, and {@link WatchEvent.Kind#DELETED} are
79+
* issued for regular files/directories in the scope of the watch, when
80+
* their current versions are different from their previous versions, as
81+
* determined using <i>last-modified-times</i>. Specifically, when an
82+
* overflow event happens:
83+
*
84+
* <ul>
85+
* <li>CREATED events are issued for all regular files/directories when the
86+
* previous last-modified-time is unknown, but the current
87+
* last-modified-time is known (i.e., the file started existing).
88+
* <li>MODIFIED events are issued for all regular files/directories when the
89+
* previous last-modified-time is before the current last-modified-time.
90+
* <li>DELETED events are issued for all regular files/directories when the
91+
* previous last-modified-time is known, but the current
92+
* last-modified-time is unknown (i.e., the file stopped existing).
93+
* </ul>
94+
*
95+
* <p>
96+
* To keep track of last-modified-times, an internal <i>index</i> is
97+
* populated with last-modified-times of all regular files/directories in
98+
* the scope of the watch when the watch is started. Each time when any
99+
* event happens, the index is updated accordingly, so when an overflow
100+
* event happens, last-modified-times can be compared as described above.
101+
*
102+
* <p>
103+
* This approach results in a small overapproximation (cf. {@link #ALL}),
104+
* but it is relatively expensive in terms of memory usage (cf. ALL), as the
105+
* watch needs to keep track of last-modified-times.
106+
*/
107+
DIFF
108+
}

src/main/java/engineering/swat/watch/WatchEvent.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,16 @@ public enum Kind {
6868
private final Path rootPath;
6969
private final Path relativePath;
7070

71+
private static final Path EMPTY_PATH = Path.of("");
72+
73+
public WatchEvent(Kind kind, Path rootPath) {
74+
this(kind, rootPath, null);
75+
}
76+
7177
public WatchEvent(Kind kind, Path rootPath, @Nullable Path relativePath) {
7278
this.kind = kind;
7379
this.rootPath = rootPath;
74-
this.relativePath = relativePath == null ? Path.of("") : relativePath;
80+
this.relativePath = relativePath == null ? EMPTY_PATH : relativePath;
7581
}
7682

7783
public Kind getKind() {
@@ -101,6 +107,20 @@ public Path calculateFullPath() {
101107
return rootPath.resolve(relativePath);
102108
}
103109

110+
/**
111+
* @return The file name of the full path of this event, or {@code null} if
112+
* it has zero elements (cf. {@link Path#getFileName()}), but without
113+
* calculating the full path. This method is equivalent to, but more
114+
* efficient than, {@code calculateFullPath().getFileName()}.
115+
*/
116+
public @Nullable Path getFileName() {
117+
var fileName = relativePath.getFileName();
118+
if (fileName == null || fileName.equals(EMPTY_PATH)) {
119+
fileName = rootPath.getFileName();
120+
}
121+
return fileName;
122+
}
123+
104124
@Override
105125
public String toString() {
106126
return String.format("WatchEvent[%s, %s, %s]", this.rootPath, this.kind, this.relativePath);

src/main/java/engineering/swat/watch/Watcher.java

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,19 @@
3232
import java.nio.file.Path;
3333
import java.util.concurrent.CompletableFuture;
3434
import java.util.concurrent.Executor;
35+
import java.util.function.BiConsumer;
3536
import java.util.function.Consumer;
37+
import java.util.function.Predicate;
3638

3739
import org.apache.logging.log4j.LogManager;
3840
import org.apache.logging.log4j.Logger;
3941

42+
import engineering.swat.watch.impl.EventHandlingWatch;
4043
import engineering.swat.watch.impl.jdk.JDKDirectoryWatch;
44+
import engineering.swat.watch.impl.jdk.JDKFileTreeWatch;
4145
import engineering.swat.watch.impl.jdk.JDKFileWatch;
42-
import engineering.swat.watch.impl.jdk.JDKRecursiveDirectoryWatch;
46+
import engineering.swat.watch.impl.overflows.IndexingRescanner;
47+
import engineering.swat.watch.impl.overflows.MemorylessRescanner;
4348

4449
/**
4550
* <p>Watch a path for changes.</p>
@@ -50,17 +55,19 @@
5055
*/
5156
public class Watcher {
5257
private final Logger logger = LogManager.getLogger();
53-
private final WatchScope scope;
5458
private final Path path;
59+
private final WatchScope scope;
60+
private volatile Approximation approximateOnOverflow = Approximation.ALL;
5561
private volatile Executor executor = CompletableFuture::runAsync;
5662

57-
private static final Consumer<WatchEvent> EMPTY_HANDLER = p -> {};
58-
private volatile Consumer<WatchEvent> eventHandler = EMPTY_HANDLER;
59-
63+
private static final BiConsumer<EventHandlingWatch, WatchEvent> EMPTY_HANDLER = (w, e) -> {};
64+
private volatile BiConsumer<EventHandlingWatch, WatchEvent> eventHandler = EMPTY_HANDLER;
65+
private static final Predicate<WatchEvent> TRUE_FILTER = e -> true;
66+
private volatile Predicate<WatchEvent> eventFilter = TRUE_FILTER;
6067

61-
private Watcher(WatchScope scope, Path path) {
62-
this.scope = scope;
68+
private Watcher(Path path, WatchScope scope) {
6369
this.path = path;
70+
this.scope = scope;
6471
}
6572

6673
/**
@@ -87,9 +94,8 @@ public static Watcher watch(Path path, WatchScope scope) {
8794
break;
8895
default:
8996
throw new IllegalArgumentException("Unsupported scope: " + scope);
90-
9197
}
92-
return new Watcher(scope, path);
98+
return new Watcher(path, scope);
9399
}
94100

95101
/**
@@ -103,7 +109,7 @@ public Watcher on(Consumer<WatchEvent> eventHandler) {
103109
if (this.eventHandler != EMPTY_HANDLER) {
104110
throw new IllegalArgumentException("on handler cannot be set more than once");
105111
}
106-
this.eventHandler = eventHandler;
112+
this.eventHandler = (w, e) -> eventHandler.accept(e);
107113
return this;
108114
}
109115

@@ -114,7 +120,7 @@ public Watcher on(WatchEventListener listener) {
114120
if (this.eventHandler != EMPTY_HANDLER) {
115121
throw new IllegalArgumentException("on handler cannot be set more than once");
116122
}
117-
this.eventHandler = ev -> {
123+
this.eventHandler = (w, ev) -> {
118124
switch (ev.getKind()) {
119125
case CREATED:
120126
listener.onCreated(ev);
@@ -135,6 +141,22 @@ public Watcher on(WatchEventListener listener) {
135141
return this;
136142
}
137143

144+
/**
145+
* Configures the event filter to determine which events should be passed to
146+
* the event handler. By default (without calling this method), all events
147+
* are passed. This method must be called at most once.
148+
* @param predicate The predicate to determine an event should be kept
149+
* ({@code true}) or dropped ({@code false})
150+
* @return {@code this} (to support method chaining)
151+
*/
152+
Watcher filter(Predicate<WatchEvent> predicate) {
153+
if (this.eventFilter != TRUE_FILTER) {
154+
throw new IllegalArgumentException("filter cannot be set more than once");
155+
}
156+
this.eventFilter = predicate;
157+
return this;
158+
}
159+
138160
/**
139161
* Optionally configure the executor in which the {@link #on(Consumer)} callbacks are scheduled.
140162
* If not defined, every task will be scheduled on the {@link java.util.concurrent.ForkJoinPool#commonPool()}.
@@ -146,6 +168,22 @@ public Watcher withExecutor(Executor callbackHandler) {
146168
return this;
147169
}
148170

171+
/**
172+
* Optionally configure which regular files/directories in the scope of the
173+
* watch an <i>approximation</i> of synthetic events (of kinds
174+
* {@link WatchEvent.Kind#CREATED}, {@link WatchEvent.Kind#MODIFIED}, and/or
175+
* {@link WatchEvent.Kind#DELETED}) should be issued when an overflow event
176+
* happens. If not defined before this watcher is started, the
177+
* {@link Approximation#ALL} approach will be used.
178+
* @param whichFiles Constant to indicate for which regular
179+
* files/directories to approximate
180+
* @return This watcher for optional method chaining
181+
*/
182+
public Watcher onOverflow(Approximation whichFiles) {
183+
this.approximateOnOverflow = whichFiles;
184+
return this;
185+
}
186+
149187
/**
150188
* Start watch the path for events.
151189
* @return a subscription for the watch, when closed, new events will stop being registered to the worker pool.
@@ -157,33 +195,48 @@ public ActiveWatch start() throws IOException {
157195
throw new IllegalStateException("There is no onEvent handler defined");
158196
}
159197

198+
var h = applyApproximateOnOverflow();
199+
160200
switch (scope) {
161201
case PATH_AND_CHILDREN: {
162-
var result = new JDKDirectoryWatch(path, executor, eventHandler, false);
202+
var result = new JDKDirectoryWatch(path, executor, h, eventFilter);
163203
result.open();
164204
return result;
165205
}
166206
case PATH_AND_ALL_DESCENDANTS: {
167207
try {
168-
var result = new JDKDirectoryWatch(path, executor, eventHandler, true);
208+
var result = new JDKDirectoryWatch(path, executor, h, eventFilter, true);
169209
result.open();
170210
return result;
171211
} catch (Throwable ex) {
172212
// no native support, use the simulation
173213
logger.debug("Not possible to register the native watcher, using fallback for {}", path);
174214
logger.trace(ex);
175-
var result = new JDKRecursiveDirectoryWatch(path, executor, eventHandler);
215+
var result = new JDKFileTreeWatch(path, executor, h, eventFilter);
176216
result.open();
177217
return result;
178218
}
179219
}
180220
case PATH_ONLY: {
181-
var result = new JDKFileWatch(path, executor, eventHandler);
221+
var result = new JDKFileWatch(path, executor, h, eventFilter);
182222
result.open();
183223
return result;
184224
}
185225
default:
186226
throw new IllegalStateException("Not supported yet");
187227
}
188228
}
229+
230+
private BiConsumer<EventHandlingWatch, WatchEvent> applyApproximateOnOverflow() {
231+
switch (approximateOnOverflow) {
232+
case NONE:
233+
return eventHandler;
234+
case ALL:
235+
return eventHandler.andThen(new MemorylessRescanner(executor));
236+
case DIFF:
237+
return eventHandler.andThen(new IndexingRescanner(executor, path, scope));
238+
default:
239+
throw new UnsupportedOperationException("No event handler has been defined yet for this overflow policy");
240+
}
241+
}
189242
}

0 commit comments

Comments
 (0)