Skip to content

Commit 440b38c

Browse files
committed
feat(concurrency): add a shared shutdown cleanup registry
1 parent 14afa2e commit 440b38c

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package io.github.intisy.utils.concurrency;
2+
3+
import java.util.ArrayDeque;
4+
import java.util.ArrayList;
5+
import java.util.Deque;
6+
import java.util.List;
7+
8+
/**
9+
* Closers that must run when the JVM stops, however it stops.
10+
*
11+
* <p>Register a closer when you acquire something a dying JVM would otherwise leak, and unregister
12+
* it once you have released it yourself. {@link #installShutdownHook()} arms the single hook that
13+
* runs whatever is still registered.
14+
*
15+
* @author Finn Birich
16+
* @implNote a finally block covers an exception and nothing else. It does not run on Ctrl+C, and it
17+
* does not run on {@code System.exit}, which a command line tool reaches on every failure path. So
18+
* an interrupted run leaves its child processes alive and its remote resources allocated with
19+
* nobody reading the result. A hard kill runs nothing at all, which is what a startup sweep exists
20+
* for; this covers the two paths a process can still act on.
21+
* @implNote one shared registry with one hook, rather than a hook per library. Several independent
22+
* shutdown hooks in a JVM run in undefined order relative to each other, so a library that kills a
23+
* process another library is still talking to produces failures that depend on thread scheduling.
24+
*/
25+
@SuppressWarnings("unused")
26+
public final class Cleanups {
27+
private static final long DEFAULT_BUDGET_MS = 10_000;
28+
private static final Cleanups SHARED = new Cleanups(DEFAULT_BUDGET_MS);
29+
30+
private static boolean hookInstalled;
31+
32+
/**
33+
* One thing to release. {@link AutoCloseable} rather than a type of our own, because the shape
34+
* is identical and callers already have methods that fit it.
35+
*/
36+
public interface Closer extends AutoCloseable {
37+
@Override
38+
void close() throws Exception;
39+
}
40+
41+
/**
42+
* A registered closer, and the handle used to unregister it again.
43+
*/
44+
public static final class Registration {
45+
private final String what;
46+
private final Closer closer;
47+
48+
Registration(String what, Closer closer) {
49+
this.what = what;
50+
this.closer = closer;
51+
}
52+
53+
public String what() {
54+
return what;
55+
}
56+
57+
public Closer closer() {
58+
return closer;
59+
}
60+
}
61+
62+
private final Deque<Registration> pending = new ArrayDeque<Registration>();
63+
private final long budgetMs;
64+
65+
public Cleanups(long budgetMs) {
66+
this.budgetMs = budgetMs;
67+
}
68+
69+
public static Cleanups shared() {
70+
return SHARED;
71+
}
72+
73+
public static synchronized void installShutdownHook() {
74+
if (hookInstalled) {
75+
return;
76+
}
77+
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
78+
@Override
79+
public void run() {
80+
SHARED.runAll();
81+
}
82+
}, "cleanups"));
83+
hookInstalled = true;
84+
}
85+
86+
public synchronized Registration register(String what, Closer closer) {
87+
Registration registration = new Registration(what, closer);
88+
pending.push(registration);
89+
return registration;
90+
}
91+
92+
/**
93+
* @implNote every caller unregisters after cleaning up itself, so a long run does not accumulate
94+
* closers for work that is already finished and the hook does not repeat it.
95+
*/
96+
public synchronized void unregister(Registration registration) {
97+
pending.remove(registration);
98+
}
99+
100+
public synchronized int pendingCount() {
101+
return pending.size();
102+
}
103+
104+
/**
105+
* @implNote reverse order of registration, guarded per closer and bounded overall. A shutdown
106+
* hook that hangs prevents the JVM from exiting at all, so exhausting the budget names what was
107+
* skipped rather than waiting: an operator who has to finish by hand needs to be told which
108+
* things.
109+
*/
110+
public void runAll() {
111+
long deadline = System.currentTimeMillis() + budgetMs;
112+
for (Registration registration = next(); registration != null; registration = next()) {
113+
if (System.currentTimeMillis() >= deadline) {
114+
reportSkipped(registration);
115+
return;
116+
}
117+
try {
118+
registration.closer().close();
119+
} catch (Exception failure) {
120+
System.err.println("cleanup '" + registration.what() + "' failed: " + failure);
121+
}
122+
}
123+
}
124+
125+
private synchronized Registration next() {
126+
return pending.poll();
127+
}
128+
129+
private void reportSkipped(Registration current) {
130+
List<String> skipped = new ArrayList<String>();
131+
skipped.add(current.what());
132+
for (Registration registration = next(); registration != null; registration = next()) {
133+
skipped.add(registration.what());
134+
}
135+
System.err.println("cleanup budget of " + budgetMs + "ms exhausted, NOT cleaned up: " + skipped);
136+
}
137+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package io.github.intisy.utils.concurrency;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.ByteArrayOutputStream;
6+
import java.io.PrintStream;
7+
import java.nio.charset.Charset;
8+
import java.util.ArrayList;
9+
import java.util.Arrays;
10+
import java.util.Collections;
11+
import java.util.List;
12+
13+
import static org.junit.jupiter.api.Assertions.assertEquals;
14+
import static org.junit.jupiter.api.Assertions.assertSame;
15+
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
17+
class CleanupsTest {
18+
19+
/**
20+
* Reverse order is not decoration: a closer registered later can depend on something an earlier
21+
* one releases, so running forwards would tear the dependency down first.
22+
*/
23+
@Test
24+
void runsClosersInReverseOrderOfRegistration() {
25+
Cleanups cleanups = new Cleanups(10_000);
26+
List<String> order = new ArrayList<String>();
27+
28+
cleanups.register("first", record(order, "first"));
29+
cleanups.register("second", record(order, "second"));
30+
cleanups.register("third", record(order, "third"));
31+
cleanups.runAll();
32+
33+
assertEquals(Arrays.asList("third", "second", "first"), order);
34+
}
35+
36+
@Test
37+
void unregisteredCloserDoesNotRun() {
38+
Cleanups cleanups = new Cleanups(10_000);
39+
List<String> order = new ArrayList<String>();
40+
41+
Cleanups.Registration kept = cleanups.register("kept", record(order, "kept"));
42+
Cleanups.Registration dropped = cleanups.register("dropped", record(order, "dropped"));
43+
cleanups.unregister(dropped);
44+
cleanups.runAll();
45+
46+
assertEquals(Collections.singletonList("kept"), order);
47+
assertEquals(0, cleanups.pendingCount());
48+
assertEquals("kept", kept.what());
49+
}
50+
51+
/**
52+
* A closer that throws must not strand the ones registered before it, which is why each is
53+
* guarded individually rather than the loop being wrapped once.
54+
*/
55+
@Test
56+
void aFailingCloserDoesNotStopTheRest() {
57+
Cleanups cleanups = new Cleanups(10_000);
58+
List<String> order = new ArrayList<String>();
59+
60+
cleanups.register("early", record(order, "early"));
61+
cleanups.register("boom", new Cleanups.Closer() {
62+
@Override
63+
public void close() {
64+
throw new IllegalStateException("closer failed");
65+
}
66+
});
67+
String errors = captureStderr(cleanups);
68+
69+
assertEquals(Collections.singletonList("early"), order);
70+
assertTrue(errors.contains("boom"), "expected the failure to name the closer, got: " + errors);
71+
}
72+
73+
/**
74+
* A shutdown hook that hangs stops the JVM exiting at all, so an exhausted budget must abandon
75+
* the remaining closers and say which ones it abandoned.
76+
*/
77+
@Test
78+
void anExhaustedBudgetAbandonsTheRestAndNamesThem() {
79+
Cleanups cleanups = new Cleanups(0);
80+
List<String> order = new ArrayList<String>();
81+
82+
cleanups.register("never-runs", record(order, "never-runs"));
83+
String errors = captureStderr(cleanups);
84+
85+
assertEquals(Collections.<String>emptyList(), order);
86+
assertTrue(errors.contains("budget"), "expected the budget message, got: " + errors);
87+
assertTrue(errors.contains("never-runs"), "expected the skipped closer named, got: " + errors);
88+
assertEquals(0, cleanups.pendingCount(), "an abandoned run must still drain the registry");
89+
}
90+
91+
@Test
92+
void pendingCountTracksRegistrationAndRun() {
93+
Cleanups cleanups = new Cleanups(10_000);
94+
assertEquals(0, cleanups.pendingCount());
95+
96+
cleanups.register("one", record(new ArrayList<String>(), "one"));
97+
cleanups.register("two", record(new ArrayList<String>(), "two"));
98+
assertEquals(2, cleanups.pendingCount());
99+
100+
cleanups.runAll();
101+
assertEquals(0, cleanups.pendingCount());
102+
}
103+
104+
@Test
105+
void sharedIsOneInstanceForTheWholeJvm() {
106+
assertSame(Cleanups.shared(), Cleanups.shared());
107+
}
108+
109+
private static Cleanups.Closer record(final List<String> order, final String name) {
110+
return new Cleanups.Closer() {
111+
@Override
112+
public void close() {
113+
order.add(name);
114+
}
115+
};
116+
}
117+
118+
private static String captureStderr(Cleanups cleanups) {
119+
PrintStream original = System.err;
120+
ByteArrayOutputStream captured = new ByteArrayOutputStream();
121+
try {
122+
System.setErr(new PrintStream(captured, true));
123+
cleanups.runAll();
124+
} finally {
125+
System.setErr(original);
126+
}
127+
return new String(captured.toByteArray(), Charset.forName("UTF-8"));
128+
}
129+
}

0 commit comments

Comments
 (0)