diff --git a/README.md b/README.md index 95fc111..06c7506 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ -# jox +# Jox [![Ideas, suggestions, problems, questions](https://img.shields.io/badge/Discourse-ask%20question-blue)](https://softwaremill.community/c/open-source/11) [![CI](https://github.com/softwaremill/jox/workflows/CI/badge.svg)](https://github.com/softwaremill/jox/actions?query=workflow%3A%22CI%22) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.softwaremill.jox/core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.softwaremill.jox/core) [![javadoc](https://javadoc.io/badge2/com.softwaremill.jox/core/javadoc.svg)](https://javadoc.io/doc/com.softwaremill.jox/core) -Fast and Scalable Channels in Java. Designed to be used with Java 21+ and virtual threads, -see [Project Loom](https://openjdk.org/projects/loom/) (although the `core` module can be used with Java 17+). +Modern concurrency for Java 21+ (backed by virtual threads, see [Project Loom](https://openjdk.org/projects/loom/)). +Includes: -Inspired by the "Fast and Scalable Channels in Kotlin Coroutines" [paper](https://arxiv.org/abs/2211.04986), and -the [Kotlin implementation](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/common/src/channels/BufferedChannel.kt). +* Fast and Scalable Channels in Java. Inspired by the "Fast and Scalable Channels in Kotlin Coroutines" + [paper](https://arxiv.org/abs/2211.04986), and + the [Kotlin implementation](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/common/src/channels/BufferedChannel.kt). +* Programmer-friendly structured concurrency +* Blocking, synchronous, functional streaming operators JavaDocs can be browsed at [https://javadoc.io](https://www.javadoc.io/doc/com.softwaremill.jox/core/latest/com.softwaremill.jox/com/softwaremill/jox/package-summary.html). @@ -25,7 +28,17 @@ Videos: * [A 10-minute introduction to Jox](https://www.youtube.com/watch?v=Ss9b1HpPDt0) * [Passing control information through channels](https://www.youtube.com/watch?v=VjiCzaiRro8) -## Dependencies +For a Scala version, see the [Ox project](https://github.com/softwaremill/ox). + +## Table of contents + +* [Channels](#channels) +* [Structured concurrency](#structured-concurrency) +* [Streaming](#streaming) + +## Channels + +### Dependency Maven: @@ -33,7 +46,7 @@ Maven: com.softwaremill.jox - core + channels 0.2.1 ``` @@ -41,18 +54,12 @@ Maven: Gradle: ```groovy -implementation 'com.softwaremill.jox:core:0.2.1' -``` - -SBT: - -```scala -libraryDependencies += "com.softwaremill.jox" % "core" % "0.2.1" +implementation 'com.softwaremill.jox:channels:0.2.1' ``` -## Usage +### Usage -### Rendezvous channel +#### Rendezvous channel ```java import com.softwaremill.jox.Channel; @@ -84,7 +91,7 @@ class Demo1 { } ``` -### Buffered channel +#### Buffered channel ```java import com.softwaremill.jox.Channel; @@ -113,7 +120,7 @@ class Demo2 { Unlimited channels can be created with `Channel.newUnlimitedChannel()`. Such channels will never block on send(). -### Closing a channel +#### Closing a channel Channels can be closed, either because the source is `done` with sending values, or when there's an `error` while the sink processes the received values. @@ -144,7 +151,7 @@ class Demo3 { } ``` -### Selecting from multiple channels +#### Selecting from multiple channels The `select` method selects exactly one clause to complete. For example, you can receive a value from exactly one channel: @@ -224,7 +231,7 @@ class Demo6 { } ``` -## Performance +### Performance The project includes benchmarks implemented using JMH - both for the `Channel`, as well as for some built-in Java synchronisation primitives (queues), as well as the Kotlin channel implementation. @@ -314,6 +321,263 @@ ParallelKotlinBenchmark.parallelChannels_defaultDispatcher 16 ChainedKotlinBenchmark.channelChain_defaultDispatcher 16 10000 avgt 20 6.039 ± 0.826 ns/op ``` +## Structured concurrency + +### Dependency + +Maven: + +```xml + + + com.softwaremill.jox + structured + 0.2.1 + +``` + +Gradle: + +```groovy +implementation 'com.softwaremill.jox:structured:0.2.1' +``` + +### Usage + +#### Creating scopes and forking computations + +```java +import java.util.concurrent.ExecutionException; + +import static com.softwaremill.jox.structured.Scopes.supervised; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException { + var result = supervised(scope -> { + var f1 = scope.fork(() -> { + Thread.sleep(500); + return 5; + }); + var f2 = scope.fork(() -> { + Thread.sleep(1000); + return 6; + }); + return f1.join() + f2.join(); + }); + System.out.println("result = " + result); + } +} +``` + +* the `supervised` scope will only complete once any forks started within complete as well +* in other words, it's guaranteed that no forks will remain running, after a `supervised` block completes +* `fork` starts a concurrently running computation, which can be joined in a blocking way. These computatioins are + backed by virtual threads + +#### Error handling in scopes + +```java +import java.util.concurrent.ExecutionException; + +import static com.softwaremill.jox.structured.Scopes.supervised; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException { + var result = supervised(scope -> { + var f1 = scope.fork(() -> { + Thread.sleep(1000); + return 6; + }); + var f2 = scope.fork(() -> { + Thread.sleep(500); + throw new RuntimeException("I can’t count to 5!"); + }); + return f1.join() + f2.join(); + }); + System.out.println("result = " + result); + } +} +``` + +* an exception thrown from the scope's body, or from any of the forks, causes the scope to end +* any forks that are still running are then interrupted +* once all forks complete, an `ExecutionException` is thrown by the `supervised` method +* the cause of the `ExecutionException` is the original exception +* any other exceptions (e.g. `InterruptedExceptions`) that have been thrown while ending the scope, are added as + suppressed + +Jox implements the "let it crash" model. When an error occurs, the entire scope ends, propagating the exception higher, +so that it can be properly handled. Moreover, no detail is lost: all exceptions are preserved, either as causes, or +suppressed exceptions. + +#### Other types of scopes & forks + +There are 4 types of forks: + +* `fork`: daemon fork, supervised; when the scope's body ends, such forks are interrupted +* `forkUser`: user fork, supervised; when the scope's body ends, the scope's method waits until such a fork completes + normally +* `forkUnsupervised`: daemon fork, unsupervised; any thrown exceptions don't cause the scope to end, but instead can be + discovered when the fork is `.join`ed +* `forkCancellable`: daemon fork, unsupervised, which can be manually cancelled (interrupted) + +There are also 2 types of scopes: + +* `supervised`: the default scope, which ends when all forks user forks complete successfully, or when there's any + exception in supervised scopes +* `unsupervised`: a scope where only unsupervised forks can be started + +#### Running computations in parallel + +```java +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static com.softwaremill.jox.structured.Par.par; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException { + var result = par(List.of(() -> { + Thread.sleep(500); + return 5; + }, () -> { + Thread.sleep(1000); + return 6; + })); + System.out.println("result = " + result); + } +} +// result = [5, 6] +``` + +Uses `supervised` scopes underneath. + +#### Racing computations + +```java +import java.util.concurrent.ExecutionException; + +import static com.softwaremill.jox.structured.Race.race; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException { + var result = race(() -> { + Thread.sleep(1000); + return 10; + }, () -> { + Thread.sleep(500); + return 5; + }); + // result will be 5, the other computation will be interrupted on the Thread.sleep + System.out.println("result = " + result); + } +} +// result = 5 +``` + +#### Timing out a computation + +```java +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static com.softwaremill.jox.structured.Race.timeout; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException { + var result = timeout(1000, () -> { + Thread.sleep(500); + return 5; + }); + System.out.println("result = " + result); + } +} +// result = 5 +``` + +## Streaming + +### Dependency + +Maven: + +```xml + + + com.softwaremill.jox + channel-ops + 0.2.1 + +``` + +Gradle: + +```groovy +implementation 'com.softwaremill.jox:channel-ops:0.2.1' +``` + +### Usage + +Using this module you can run operations on streams which require starting background threads. To do that, +you need to pass an active concurrency scope (started using `supervised`) to the `SourceOps` constructor. + +Each method from `SourceOps` causes a new fork (virtual thread) to be started, which starts running its logic +immediately (producing elements / consuming and transforming elements from the given source). Thus, this is an +implementation of "hot streams". + +#### Creating streams + +Sources from iterables, or tick-sources, can be created by calling methods on `SourceOps`: + +```java +import java.util.concurrent.ExecutionException; + +import static com.softwaremill.jox.structured.Scopes.supervised; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException { + supervised(scope -> { + new SourceOps(scope) + .tick(500, "tick") + .toSource() + .forEach(v -> System.out.println(v)); + return null; // unreachable, as `tick` produces infinitely many elements + }); + } +} +``` + +A tick-source can also be used in the usual way, by calling `.receive` on it, or by using it in `select`'s clauses. + +#### Transforming streams + +Streams can be transformed by calling the appropriate methods on the object returned by +`SourceOps.forSource(scope, source)`. + +`collect` combines the functionality of `map` and `filter`: elements are mapped, and when the mapping function returns +`null`, the element is skipped: + +```java +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static com.softwaremill.jox.structured.Scopes.supervised; + +public class Demo { + public static void main(String[] args) throws ExecutionException, InterruptedException { + var result = supervised(scope -> new SourceOps(scope) + .fromIterable(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) + .collect(n -> { + if (n % 2 == 0) return null; + else return n * 10; + }) + .toSource().toList()); + System.out.println("result = " + result); + } +} +// result = [10, 30, 50, 70, 90] +``` + ## Feedback Is what we are looking for! diff --git a/bench/pom.xml b/bench/pom.xml index cc4b96d..2754395 100644 --- a/bench/pom.xml +++ b/bench/pom.xml @@ -47,16 +47,6 @@ - - org.apache.maven.plugins - maven-compiler-plugin - 3.11.0 - - - --enable-preview - - - org.apache.maven.plugins maven-shade-plugin diff --git a/channel-ops/pom.xml b/channel-ops/pom.xml new file mode 100644 index 0000000..dd19617 --- /dev/null +++ b/channel-ops/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + com.softwaremill.jox + parent + 0.2.1 + + + channel-ops + 0.2.1 + jar + + + + org.junit.jupiter + junit-jupiter + test + + + org.awaitility + awaitility + test + + + com.softwaremill.jox + channels + 0.2.1 + + + com.softwaremill.jox + structured + 0.2.1 + + + diff --git a/channel-ops/src/main/java/com/softwaremill/jox/ops/SourceOps.java b/channel-ops/src/main/java/com/softwaremill/jox/ops/SourceOps.java new file mode 100644 index 0000000..04d54d0 --- /dev/null +++ b/channel-ops/src/main/java/com/softwaremill/jox/ops/SourceOps.java @@ -0,0 +1,134 @@ +package com.softwaremill.jox.ops; + +import com.softwaremill.jox.*; +import com.softwaremill.jox.structured.Scope; + +import java.util.Iterator; +import java.util.function.Function; + +public class SourceOps { + private final Scope scope; + private final int defaultCapacity; + + public SourceOps(Scope scope) { + this(scope, 16); + } + + public SourceOps(Scope scope, int defaultCapacity) { + this.scope = scope; + this.defaultCapacity = defaultCapacity; + } + + public static ForSource forSource(Scope scope, Source s) { + var sourceOps = new SourceOps(scope); + return sourceOps.new ForSource<>(s); + } + + public class ForSource { + private final Source source; + + ForSource(Source source) { + this.source = source; + } + + public Source toSource() { + return source; + } + + /** + * Applies the given mapping function {@code f} to each element received from this source, and sends the + * results to the returned channel. If {@code f} returns {@code null}, the value will be skipped. + *

+ * Errors from this channel are propagated to the returned channel. Any exceptions that occur when invoking + * {@code f} are propagated as errors to the returned channel as well. + *

+ * For a lazily-evaluated version, see {@link Channel#collectAsView(Function)}. + * + * @param f The mapping function. + * @return Ops on a source, onto which results of the mapping function will be sent. + */ + public ForSource collect(Function f) { + var c2 = new Channel(defaultCapacity); + scope.fork(() -> { + var repeat = true; + while (repeat) { + switch (source.receiveOrClosed()) { + case ChannelDone cd -> { + c2.doneOrClosed(); + repeat = false; + } + case ChannelError ce -> { + c2.errorOrClosed(ce.cause()); + repeat = false; + } + case Object t -> { + try { + var u = f.apply((T) t); + if (u != null) { + repeat = !(c2.sendOrClosed(u) instanceof ChannelClosed); + } // else skip & continue + } catch (Exception e) { + c2.errorOrClosed(e); + } + } + } + } + return null; + }); + return new ForSource(c2); + } + } + + // + + public ForSource fromIterator(Iterator i) { + var c = new Channel(defaultCapacity); + scope.fork(() -> { + try { + while (i.hasNext()) { + c.sendOrClosed(i.next()); + } + c.doneOrClosed(); + } catch (Exception e) { + c.errorOrClosed(e); + } + return null; + }); + return new ForSource(c); + } + + public ForSource fromIterable(Iterable i) { + return fromIterator(i.iterator()); + } + + /** + * Creates a rendezvous channel (without a buffer, regardless of the default capacity), to which the given value is + * sent repeatedly, at least {@code intervalMillis}ms apart between each two elements. The first value is sent + * immediately. + *

+ * The interval is measured between the subsequent invocations of the {@code send(value)} method. Hence, if there's + * a slow consumer, the next tick can be sent right after the previous one is received (if it was received later + * than the inter-tick interval duration). However, ticks don't accumulate, e.g. when the consumer is so slow that + * multiple intervals pass between {@code send} invocations. + *

+ * Must be run within a scope, since a child fork is created which sends the ticks, and waits until the next tick + * can be sent. + * + * @param intervalMillis The temporal spacing between subsequent ticks. + * @param tickValue The value to send to the channel on every tick. + * @return Ops on a source to which the tick values are sent. + */ + public ForSource tick(long intervalMillis, T tickValue) { + var c = new Channel(); + scope.fork(() -> { + while (true) { + var start = System.nanoTime(); + c.sendOrClosed(tickValue); + var end = System.nanoTime(); + var sleep = intervalMillis * 1_000_000 - (end - start); + if (sleep > 0) Thread.sleep(sleep / 1_000_000, (int) sleep % 1_000_000); + } + }); + return new ForSource(c); + } +} diff --git a/channel-ops/src/test/java/com/softwaremill/jox/ops/SourceOpsCollectTest.java b/channel-ops/src/test/java/com/softwaremill/jox/ops/SourceOpsCollectTest.java new file mode 100644 index 0000000..8e09e12 --- /dev/null +++ b/channel-ops/src/test/java/com/softwaremill/jox/ops/SourceOpsCollectTest.java @@ -0,0 +1,101 @@ +package com.softwaremill.jox.ops; + +import com.softwaremill.jox.Channel; +import com.softwaremill.jox.ChannelDone; +import com.softwaremill.jox.Source; +import com.softwaremill.jox.structured.Scopes; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SourceOpsCollectTest { + @Test + void testMapOverSource() throws Exception { + Scopes.supervised(scope -> { + Channel c = new Channel(); + scope.fork(() -> { + c.send(1); + c.send(2); + c.send(3); + c.done(); + return null; + }); + + Source s = SourceOps.forSource(scope, c).collect(x -> x * 10).toSource(); + + assertEquals(10, s.receive()); + assertEquals(20, s.receive()); + assertEquals(30, s.receive()); + assertEquals(new ChannelDone(), s.receiveOrClosed()); + return null; + }); + } + + @Test + void testCollectOverSource() throws Exception { + Scopes.supervised(scope -> { + Channel c = new Channel(); + scope.fork(() -> { + c.send(1); + c.send(2); + c.send(3); + c.send(4); + c.send(5); + c.done(); + return null; + }); + + Source s = SourceOps.forSource(scope, c).collect(x -> { + if (x % 2 == 0) return x * 10; + else return null; + }).toSource(); + + assertEquals(20, s.receive()); + assertEquals(40, s.receive()); + assertEquals(new ChannelDone(), s.receiveOrClosed()); + return null; + }); + } + + @Test + void testCollectOverSourceStressTest() throws Exception { + for (int i = 0; i < 100000; i++) { + Scopes.supervised(scope -> { + Channel c = new Channel(); + scope.fork(() -> { + c.send(1); + c.done(); + return null; + }); + + Source s = SourceOps.forSource(scope, c).collect(x -> x * 10).toSource(); + + assertEquals(10, s.receive()); + assertEquals(new ChannelDone(), s.receiveOrClosed()); + return null; + }); + } + } + + @Test + void testCollectOverSourceUsingForSyntax() throws Exception { + Scopes.supervised(scope -> { + Channel c = new Channel(); + scope.fork(() -> { + c.send(1); + c.send(2); + c.send(3); + c.done(); + return null; + }); + + Source s = SourceOps.forSource(scope, c).collect(x -> x * 2).toSource(); + + assertEquals(2, s.receive()); + assertEquals(4, s.receive()); + assertEquals(6, s.receive()); + assertEquals(new ChannelDone(), s.receiveOrClosed()); + return null; + }); + } +} diff --git a/channel-ops/src/test/java/com/softwaremill/jox/ops/SourceOpsTickTest.java b/channel-ops/src/test/java/com/softwaremill/jox/ops/SourceOpsTickTest.java new file mode 100644 index 0000000..5f27da3 --- /dev/null +++ b/channel-ops/src/test/java/com/softwaremill/jox/ops/SourceOpsTickTest.java @@ -0,0 +1,51 @@ +package com.softwaremill.jox.ops; + +import com.softwaremill.jox.structured.Scopes; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SourceOpsTickTest { + @Test + void testTickRegularly() throws Exception { + Scopes.supervised(scope -> { + long start = System.currentTimeMillis(); + var c = new SourceOps(scope).tick(100, "tick").toSource(); + + assertEquals("tick", c.receive()); + long elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed >= 0L && elapsed <= 50L); + + assertEquals("tick", c.receive()); + elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed >= 100L && elapsed <= 150L); + + assertEquals("tick", c.receive()); + elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed >= 200L && elapsed <= 250L); + + return null; + }); + } + + @Test + void testTickImmediatelyInCaseOfSlowConsumerAndThenResumeNormal() throws Exception { + Scopes.supervised(scope -> { + long start = System.currentTimeMillis(); + var c = new SourceOps(scope).tick(100, "tick").toSource(); + + // Simulating a slow consumer + Thread.sleep(200); + assertEquals("tick", c.receive()); // a tick should be waiting + long elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed >= 200L && elapsed <= 250L); + + assertEquals("tick", c.receive()); // and immediately another, as the interval between send-s has passed + elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed >= 200L && elapsed <= 250L); + + return null; + }); + } +} diff --git a/channels/pom.xml b/channels/pom.xml index d2e7c73..4b43a13 100644 --- a/channels/pom.xml +++ b/channels/pom.xml @@ -13,50 +13,16 @@ 0.2.1 jar - - UTF-8 - 17 - 17 - 21 - 21 - - org.junit.jupiter junit-jupiter - 5.10.1 test org.awaitility awaitility - 4.2.0 test - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.12.1 - - - true - --enable-preview - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.2 - - --enable-preview - - - - diff --git a/channels/src/main/java/com/softwaremill/jox/Source.java b/channels/src/main/java/com/softwaremill/jox/Source.java index cbbe576..f23f53b 100644 --- a/channels/src/main/java/com/softwaremill/jox/Source.java +++ b/channels/src/main/java/com/softwaremill/jox/Source.java @@ -1,5 +1,8 @@ package com.softwaremill.jox; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -59,4 +62,34 @@ default Source collectAsView(Function f) { default Source filterAsView(Predicate p) { return new CollectSource<>(this, t -> p.test(t) ? t : null); } + + // draining operations + + + /** + * Invokes the given function for each received element. Blocks until the channel is done. + * + * @throws ChannelErrorException When there is an upstream error. + */ + default void forEach(Consumer c) throws InterruptedException { + var repeat = true; + while (repeat) { + switch (receiveOrClosed()) { + case ChannelDone cd -> repeat = false; + case ChannelError ce -> throw ce.toException(); + case Object t -> c.accept((T) t); + } + } + } + + /** + * Accumulates all elements received from the channel into a list. Blocks until the channel is done. + * + * @throws ChannelErrorException When there is an upstream error. + */ + default List toList() throws InterruptedException { + var l = new ArrayList(); + forEach(l::add); + return l; + } } diff --git a/channels/src/test/java/com/softwaremill/jox/SourceOpsForEachTest.java b/channels/src/test/java/com/softwaremill/jox/SourceOpsForEachTest.java new file mode 100644 index 0000000..8a0da87 --- /dev/null +++ b/channels/src/test/java/com/softwaremill/jox/SourceOpsForEachTest.java @@ -0,0 +1,37 @@ +package com.softwaremill.jox; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +public class SourceOpsForEachTest { + @Test + void testIterateOverSource() throws Exception { + var c = new Channel(10); + c.sendOrClosed(1); + c.sendOrClosed(2); + c.sendOrClosed(3); + c.doneOrClosed(); + + List r = new ArrayList<>(); + c.forEach(v -> r.add(v)); + + assertIterableEquals(List.of(1, 2, 3), r); + } + + @Test + void testConvertSourceToList() throws Exception { + var c = new Channel(10); + c.sendOrClosed(1); + c.sendOrClosed(2); + c.sendOrClosed(3); + c.doneOrClosed(); + + List resultList = c.toList(); + assertEquals(List.of(1, 2, 3), resultList); + } +} diff --git a/pom.xml b/pom.xml index b0ceafd..9ee2a54 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,9 @@ channels + structured bench + channel-ops @@ -47,6 +49,44 @@ + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + --enable-preview + + + + + + + + + + org.junit.jupiter + junit-jupiter + 5.10.1 + + + org.awaitility + awaitility + 4.2.0 + + + + diff --git a/structured/pom.xml b/structured/pom.xml new file mode 100644 index 0000000..2445e33 --- /dev/null +++ b/structured/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.softwaremill.jox + parent + 0.2.1 + + + structured + 0.2.1 + jar + + + + org.junit.jupiter + junit-jupiter + test + + + org.awaitility + awaitility + test + + + diff --git a/structured/src/main/java/com/softwaremill/jox/structured/CancellableFork.java b/structured/src/main/java/com/softwaremill/jox/structured/CancellableFork.java new file mode 100644 index 0000000..35f0787 --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/CancellableFork.java @@ -0,0 +1,47 @@ +package com.softwaremill.jox.structured; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +public interface CancellableFork extends Fork { + /** + * Interrupts the fork, and blocks until it completes with a result. + * + * @throws ExecutionException When the cancelled fork threw an exception. + */ + T cancel() throws InterruptedException, ExecutionException; + + /** + * Interrupts the fork, and returns immediately, without waiting for the fork to complete. Note that the enclosing scope will only + * complete once all forks have completed. + */ + void cancelNow(); +} + +class CancellableForkUsingResult extends ForkUsingResult implements CancellableFork { + private final Semaphore done; + private final AtomicBoolean started; + + CancellableForkUsingResult(CompletableFuture result, Semaphore done, AtomicBoolean started) { + super(result); + this.done = done; + this.started = started; + } + + @Override + public T cancel() throws InterruptedException, ExecutionException { + cancelNow(); + return join(); + } + + @Override + public void cancelNow() { + // will cause the scope to end, interrupting the task if it hasn't yet finished (or potentially never starting it) + done.release(); + if (!started.getAndSet(true)) { + result.completeExceptionally(new InterruptedException("fork was cancelled before it started")); + } + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Fork.java b/structured/src/main/java/com/softwaremill/jox/structured/Fork.java new file mode 100644 index 0000000..369d7a0 --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Fork.java @@ -0,0 +1,32 @@ +package com.softwaremill.jox.structured; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** + * A fork started using {@link Scope#fork}, {@link Scope#forkUser}, {@link UnsupervisedScope#forkCancellable} or + * {@link UnsupervisedScope#forkUnsupervised}, backed by a (virtual) thread. + */ +public interface Fork { + /** + * Blocks until the fork completes with a result. + * + * @throws ExecutionException If the fork completed with an exception, and is unsupervised (started with + * {@link UnsupervisedScope#forkUnsupervised} or + * {@link UnsupervisedScope#forkCancellable}). + */ + T join() throws InterruptedException, ExecutionException; +} + +class ForkUsingResult implements Fork { + protected final CompletableFuture result; + + ForkUsingResult(CompletableFuture result) { + this.result = result; + } + + @Override + public T join() throws InterruptedException, ExecutionException { + return result.get(); + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Par.java b/structured/src/main/java/com/softwaremill/jox/structured/Par.java new file mode 100644 index 0000000..46137ef --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Par.java @@ -0,0 +1,49 @@ +package com.softwaremill.jox.structured; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; + +import static com.softwaremill.jox.structured.Scopes.supervised; + +public class Par { + /** + * Runs the given computations in parallel. If any fails because of an exception, or if any returns an application + * error, other computations are interrupted. Then, the exception is re-thrown, or the error value returned. + */ + public static List par(List> fs) throws ExecutionException, InterruptedException { + return supervised(scope -> { + var forks = fs.stream().map(f -> scope.fork(f)).toList(); + var results = new ArrayList(); + for (Fork fork : forks) { + results.add(fork.join()); + } + return results; + }); + } + + /** + * Runs the given computations in parallel, with at most {@code parallelism} running in parallel at the same + * time. If any computation fails because of an exception, or if any returns an application error, other + * computations are interrupted. Then, the exception is re-thrown, or the error value returned. + */ + public static List parLimit(int parallelism, List> fs) throws ExecutionException, InterruptedException { + return supervised(scope -> { + var s = new Semaphore(parallelism); + var forks = fs.stream().map(f -> scope.fork(() -> { + s.acquire(); + var r = f.call(); + // no try-finally as there's no point in releasing in case of an exception, as any newly started forks will be interrupted + s.release(); + return r; + })).toList(); + var results = new ArrayList(); + for (Fork fork : forks) { + results.add(fork.join()); + } + return results; + }); + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Race.java b/structured/src/main/java/com/softwaremill/jox/structured/Race.java new file mode 100644 index 0000000..3d3278d --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Race.java @@ -0,0 +1,139 @@ +package com.softwaremill.jox.structured; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static com.softwaremill.jox.structured.Scopes.unsupervised; + +public class Race { + /** + * The result of computation {@code f}, if it took less than {@code millis} ms, and a {@link TimeoutException} + * otherwise. + * + * @throws TimeoutException If {@code f} took more than {@code millis}. + */ + public static T timeout(long millis, Callable f) throws TimeoutException, ExecutionException, InterruptedException { + var result = raceResult(f::call, () -> { + Thread.sleep(millis); + return new Timeout(); + }); + + if (result instanceof Timeout) { + throw new TimeoutException("Computation didn't finish within " + millis + "ms"); + } else { + return (T) result; + } + } + + /** + * Returns the result of the first computation to complete successfully, or if all fail - throws the first + * exception. + */ + public static T race(Callable f1, Callable f2) throws ExecutionException, InterruptedException { + return race(List.of(f1, f2)); + } + + /** + * Returns the result of the first computation to complete successfully, or if all fail - throws the first + * exception. + */ + public static T race(Callable f1, Callable f2, Callable f3) throws ExecutionException, InterruptedException { + return race(List.of(f1, f2, f3)); + } + + /** + * Returns the result of the first computation to complete successfully, or if all fail - throws the first + * exception. + */ + public static T race(List> fs) throws ExecutionException, InterruptedException { + var exceptions = new ArrayDeque(); + + try { + return unsupervised(scope -> { + var branchResults = new ArrayBlockingQueue<>(fs.size()); + fs.forEach(f -> { + scope.forkUnsupervised(() -> { + try { + var r = f.call(); + if (r == null) { + branchResults.add(new NullWrapperInRace()); + } else { + branchResults.add(r); + } + } catch (Exception e) { + branchResults.add(new ExceptionWrapperInRace(e)); + } + return null; + }); + }); + + var left = fs.size(); + while (left > 0) { + var first = branchResults.take(); + if (first instanceof ExceptionWrapperInRace ew) { + exceptions.add(ew.e); + } else if (first instanceof NullWrapperInRace) { + return null; + } else { + return (T) first; + } + left -= 1; + } + + // if we get here, there must be an exception + throw exceptions.pollFirst(); + }); + } catch (ExecutionException e) { + while (!exceptions.isEmpty()) { + e.addSuppressed(exceptions.pollFirst()); + } + throw e; + } + } + + + /** + * Returns the result of the first computation to complete (either successfully or with an exception). + */ + public static T raceResult(Callable f1, Callable f2) throws ExecutionException, InterruptedException { + return raceResult(List.of(f1, f2)); + } + + /** + * Returns the result of the first computation to complete (either successfully or with an exception). + */ + public static T raceResult(Callable f1, Callable f2, Callable f3) throws ExecutionException, InterruptedException { + return raceResult(List.of(f1, f2, f3)); + } + + /** + * Returns the result of the first computation to complete (either successfully or with an exception). + */ + public static T raceResult(List> fs) throws ExecutionException, InterruptedException { + var result = race(fs.stream().>map(f -> () -> { + try { + return f.call(); + } catch (Exception e) { + return new ExceptionWrapperInRaceResult(e); + } + }).toList()); + if (result instanceof ExceptionWrapperInRaceResult ew) { + throw new ExecutionException(ew.e); + } else { + return (T) result; + } + } + + private record NullWrapperInRace() {} + + private record ExceptionWrapperInRace(Exception e) {} + + private record ExceptionWrapperInRaceResult(Exception e) {} + + private record Timeout() {} +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Scope.java b/structured/src/main/java/com/softwaremill/jox/structured/Scope.java new file mode 100644 index 0000000..9128732 --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Scope.java @@ -0,0 +1,106 @@ +package com.softwaremill.jox.structured; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.StructuredTaskScope; + +/** + * Capability granted by an {@link Scopes#supervised(Scoped)} or {@link Scopes#unsupervised(ScopedUnsupervised)} + * concurrency scope. + *

+ * Represents a capability to fork supervised or unsupervised, asynchronously running computations in a concurrency + * scope. Such forks can be created using {@link Scope#fork}, {@link Scope#forkUser}, + * {@link UnsupervisedScope#forkCancellable} or {@link UnsupervisedScope#forkUnsupervised}. + * + * @see ScopedUnsupervised + */ +public class Scope extends UnsupervisedScope { + private final StructuredTaskScope scope; + private final Supervisor supervisor; + + Scope(Supervisor supervisor) { + this.scope = new DoNothingScope(); + this.supervisor = supervisor; + } + + @Override + StructuredTaskScope getScope() { + return scope; + } + + @Override + Supervisor getSupervisor() { + return supervisor; + } + + /** + * Starts a fork (logical thread of execution), which is guaranteed to complete before the enclosing + * {@link Scopes#supervised(Scoped)} block completes. + *

+ * The fork behaves as a daemon thread. That is, if the body of the scope completes successfully, and all other user + * forks (created using {@link #forkUser(Callable)}) complete successfully, the scope will end, cancelling all + * running forks (including this one, if it's still running). That is, successful completion of this fork isn't + * required to end the scope. + *

+ * An exception thrown while evaluating {@code f} will cause the fork to fail and the enclosing scope to end + * (cancelling all other running forks). + *

+ * For alternate behaviors regarding ending the scope, see {@link #forkUser}, + * {@link UnsupervisedScope#forkCancellable} and {@link UnsupervisedScope#forkUnsupervised}. + */ + public Fork fork(Callable f) { + var result = new CompletableFuture(); + getScope().fork(() -> { + try { + result.complete(f.call()); + } catch (Throwable e) { + // we notify the supervisor first, so that if this is the first failing fork in the scope, the supervisor will + // get first notified of the exception by the "original" (this) fork + // if the supervisor doesn't end the scope, the exception will be thrown when joining the result; otherwise, not + // completing the result; any joins will end up being interrupted + if (!supervisor.forkException(e)) { + result.completeExceptionally(e); + } + } + return null; + }); + return new ForkUsingResult(result); + } + + /** + * Starts a fork (logical thread of execution), which is guaranteed to complete before the enclosing + * {@link Scopes#supervised(Scoped)} block completes. + *

+ * The fork behaves as a user-level thread. That is, the scope won't end until the body of the scope, and all other + * user forks (including this one) complete successfully. That is, successful completion of this fork is required to + * end the scope. + *

+ * An exception thrown while evaluating {@code f} will cause the enclosing scope to end (cancelling all other + * running forks). + *

+ * For alternate behaviors regarding ending the scope, see {@link #fork}, {@link UnsupervisedScope#forkCancellable} + * and {@link UnsupervisedScope#forkUnsupervised}. + */ + public Fork forkUser(Callable f) { + var result = new CompletableFuture(); + getSupervisor().forkStarts(); + getScope().fork(() -> { + try { + result.complete(f.call()); + getSupervisor().forkSuccess(); + } catch (Throwable e) { + if (!supervisor.forkException(e)) { + result.completeExceptionally(e); + } + } + return null; + }); + return new ForkUsingResult(result); + } +} + +class DoNothingScope extends StructuredTaskScope { + public DoNothingScope() { + super(null, Thread.ofVirtual().factory()); + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Scoped.java b/structured/src/main/java/com/softwaremill/jox/structured/Scoped.java new file mode 100644 index 0000000..e14897d --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Scoped.java @@ -0,0 +1,8 @@ +package com.softwaremill.jox.structured; + +/** + * A functional interface, capturing a computation which runs using the {@link Scope} capability to fork computations. + */ +public interface Scoped { + T run(Scope scope) throws Exception; +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/ScopedUnsupervised.java b/structured/src/main/java/com/softwaremill/jox/structured/ScopedUnsupervised.java new file mode 100644 index 0000000..1fb3d8f --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/ScopedUnsupervised.java @@ -0,0 +1,9 @@ +package com.softwaremill.jox.structured; + +/** + * A functional interface, capturing a computation which runs using the {@link ScopedUnsupervised} capability to fork + * computations. + */ +public interface ScopedUnsupervised { + T run(UnsupervisedScope scope) throws Exception; +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Scopes.java b/structured/src/main/java/com/softwaremill/jox/structured/Scopes.java new file mode 100644 index 0000000..4de2c07 --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Scopes.java @@ -0,0 +1,105 @@ +package com.softwaremill.jox.structured; + +import java.util.concurrent.ExecutionException; + +public class Scopes { + /** + * Starts a new concurrency scope, which allows starting forks in the given code block {@code f}. Forks can be + * started using {@link UnsupervisedScope#forkUnsupervised} and {@link UnsupervisedScope#forkCancellable}. All forks + * are guaranteed to complete before this scope completes. + *

+ * It is advisable to use {@link #supervised(Scoped)} scopes if possible, as they minimise the chances of an error + * to go unnoticed. + *

+ * The scope is ran in unsupervised mode, that is: + * + *

    + *
  • the scope ends once the {@code f} body completes; this causes any running forks started within + * {@code f} to be cancelled + *
  • the scope completes (that is, this method returns) only once all forks started by {@code f} have + * completed (either successfully, or with an exception) + *
  • fork failures aren't handled in any special way, but can be inspected using {@link Fork#join()} + *
+ *

+ * Upon successful completion, returns the result of evaluating {@code f}. Upon failure, that is an exception + * thrown by {@code f}, it is re-thrown, wrapped with an {@link ExecutionException}. + * + * @throws ExecutionException When the scope's body throws an exception + * @see #supervised(Scoped) Starts a scope in supervised mode + */ + public static T unsupervised(ScopedUnsupervised f) throws ExecutionException, InterruptedException { + return scopedWithCapability(new Scope(new NoOpSupervisor()), f::run); + } + + /** + * Starts a new concurrency scope, which allows starting forks in the given code block {@code f}. Forks can be + * started using {@link Scope#fork}, {@link Scope#forkUser}, {@link UnsupervisedScope#forkCancellable} or + * {@link UnsupervisedScope#forkUnsupervised}. All forks are guaranteed to complete before this scope completes. + *

+ * The scope is ran in supervised mode, that is: + * + *

    + *
  • the scope ends once all user, supervised forks (started using {@link Scope#forkUser}), including the + * {@code f} body, succeed. Forks started using {@link Scope#fork} (daemon) don't have to complete + * successfully for the scope to end. + *
  • the scope also ends once the first supervised fork (including the {@code f} main body) fails with an + * exception + *
  • when the scope ends, all running forks are cancelled + *
  • the scope completes (that is, this method returns) only once all forks started by + * {@code f} have completed (either successfully, or with an exception) + *
+ *

+ * Upon successful completion, returns the result of evaluating {@code f}. Upon failure, the exception that + * caused the scope to end is re-thrown, wrapped in an {@link ExecutionException} (regardless if the exception was + * thrown from the main body, or from a fork). Any other exceptions that occur when completing the scope are added + * as suppressed. + * + * @throws ExecutionException When the main body, or any of the forks, throw an exception + * @see #unsupervised(ScopedUnsupervised) Starts a scope in unsupervised mode + */ + public static T supervised(Scoped f) throws ExecutionException, InterruptedException { + var s = new DefaultSupervisor(); + var capability = new Scope(s); + try { + var rawScope = capability.getScope(); + try { + try { + var mainBodyFork = capability.forkUser(() -> f.run(capability)); + // might throw if any supervised fork threw + s.join(); + // if no exceptions, the main f-fork must be done by now + return mainBodyFork.join(); + } finally { + rawScope.shutdown(); + rawScope.join(); + } + // join might have been interrupted + } finally { + rawScope.close(); + } + } catch (Throwable e) { + // all forks are guaranteed to have finished: some might have ended up throwing exceptions (InterruptedException or + // others), but only the first one is propagated below. That's why we add all the other exceptions as suppressed. + s.addSuppressedErrors(e); + throw e; + } + } + + static T scopedWithCapability(Scope capability, Scoped f) throws ExecutionException, InterruptedException { + var scope = capability.getScope(); + + try { + try { + return f.run(capability); + } catch (Exception e) { + throw new ExecutionException(e); + } finally { + scope.shutdown(); + scope.join(); + } + // join might have been interrupted + } finally { + scope.close(); + } + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Supervisor.java b/structured/src/main/java/com/softwaremill/jox/structured/Supervisor.java new file mode 100644 index 0000000..36301c4 --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Supervisor.java @@ -0,0 +1,65 @@ +package com.softwaremill.jox.structured; + +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +sealed interface Supervisor permits NoOpSupervisor, DefaultSupervisor { + void forkStarts(); + + void forkSuccess(); + + boolean forkException(Throwable e); +} + +final class NoOpSupervisor implements Supervisor { + @Override + public void forkStarts() {} + + @Override + public void forkSuccess() {} + + @Override + public boolean forkException(Throwable e) {return false;} +} + +final class DefaultSupervisor implements Supervisor { + private final AtomicInteger running = new AtomicInteger(0); + private final CompletableFuture result = new CompletableFuture<>(); + private final Set otherExceptions = ConcurrentHashMap.newKeySet(); + + @Override + public void forkStarts() { + running.incrementAndGet(); + } + + @Override + public void forkSuccess() { + int v = running.decrementAndGet(); + if (v == 0) { + result.complete(null); + } + } + + @Override + public boolean forkException(Throwable e) { + if (!result.completeExceptionally(e)) { + otherExceptions.add(e); + } + return true; + } + + public void join() throws ExecutionException, InterruptedException { + result.get(); + } + + public void addSuppressedErrors(Throwable e) { + for (Throwable e2 : otherExceptions) { + if (!e.equals(e2)) { + e.addSuppressed(e2); + } + } + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/UnsupervisedScope.java b/structured/src/main/java/com/softwaremill/jox/structured/UnsupervisedScope.java new file mode 100644 index 0000000..b06fd71 --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/UnsupervisedScope.java @@ -0,0 +1,94 @@ +package com.softwaremill.jox.structured; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.StructuredTaskScope; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.softwaremill.jox.structured.Scopes.scopedWithCapability; + +/** + * Capability granted by an {@link Scopes#unsupervised(ScopedUnsupervised)} concurrency scope (as well as, via + * subtyping, by {@link Scopes#supervised(Scoped)}). + *

+ * Represents a capability to fork unsupervised, asynchronously running computations in a concurrency scope. Such forks + * can be created using {@link UnsupervisedScope#forkUnsupervised} or {@link UnsupervisedScope#forkCancellable}. + * + * @see Scopes#supervised(Scoped) + */ +public abstract class UnsupervisedScope { + abstract StructuredTaskScope getScope(); + + abstract Supervisor getSupervisor(); + + /** + * Starts a fork (logical thread of execution), which is guaranteed to complete before the enclosing + * {@link Scopes#supervised(Scoped)}, or {@link Scopes#unsupervised(ScopedUnsupervised)} block completes. + *

+ * In case an exception is thrown while evaluating {@code f}, it will be thrown when calling the returned + * {@link Fork}'s .join() method. + *

+ * Success or failure isn't signalled to the enclosing scope, and doesn't influence the scope's lifecycle. + *

+ * For alternate behaviors, see {@link Scope#fork}, {@link Scope#forkUser}, {@link #forkCancellable}. + */ + public Fork forkUnsupervised(Callable f) { + var result = new CompletableFuture(); + getScope().fork(() -> { + try { + result.complete(f.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + return null; + }); + return new ForkUsingResult<>(result); + } + + /** + * Starts a fork (logical thread of execution), which is guaranteed to complete before the enclosing + * {@link Scopes#supervised(Scoped)}, or {@link Scopes#unsupervised(ScopedUnsupervised)} block completes, and which + * can be cancelled on-demand. + *

+ * In case an exception is thrown while evaluating {@code f}, it will be thrown when calling the returned + * {@link CancellableFork}'s {@code .join()} method. + *

+ * The fork is unsupervised (similarly to {@link #forkUnsupervised(Callable)}), hence success or failure isn't + * signalled to the enclosing scope and doesn't influence the scope's lifecycle. + *

+ * For alternate behaviors, see {@link Scope#fork}, {@link Scope#forkUser} and {@link #forkUnsupervised}. + *

+ * Implementation note: a cancellable fork is created by starting a nested scope in a fork, and then starting a fork + * there. Hence, it is more expensive than {@link Scope#fork}, as two virtual threads are started. + */ + public CancellableFork forkCancellable(Callable f) { + var result = new CompletableFuture(); + // forks can be never run, if they are cancelled immediately - we need to detect this, not to await on result.get() + var started = new AtomicBoolean(false); + // interrupt signal + var done = new Semaphore(0); + getScope().fork(() -> { + var nestedCapability = new Scope(new NoOpSupervisor()); + scopedWithCapability(nestedCapability, cap2 -> { + nestedCapability.getScope().fork(() -> { + // "else" means that the fork is already cancelled, so doing nothing in that case + if (!started.getAndSet(true)) { + try { + result.complete(f.call()); + } catch (Exception e) { + result.completeExceptionally(e); + } + } + + done.release(); // the nested scope can now finish + return null; + }); + done.acquire(); + return null; + }); + return null; + }); + return new CancellableForkUsingResult<>(result, done, started); + } +} diff --git a/structured/src/main/java/com/softwaremill/jox/structured/Util.java b/structured/src/main/java/com/softwaremill/jox/structured/Util.java new file mode 100644 index 0000000..92104ca --- /dev/null +++ b/structured/src/main/java/com/softwaremill/jox/structured/Util.java @@ -0,0 +1,30 @@ +package com.softwaremill.jox.structured; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; + +public class Util { + /** + * Prevent {@code f} from being interrupted. Any interrupted exceptions that occur while evaluating + * {@code f} will be re-thrown once it completes. + */ + public static T uninterruptible(Callable f) throws ExecutionException, InterruptedException { + return Scopes.unsupervised(c -> { + var fork = c.forkUnsupervised(f); + InterruptedException caught = null; + try { + while (true) { + try { + return fork.join(); + } catch (InterruptedException e) { + caught = e; + } + } + } finally { + if (caught != null) { + throw caught; + } + } + }); + } +} diff --git a/structured/src/main/java/module-info.java b/structured/src/main/java/module-info.java new file mode 100644 index 0000000..a3b58a1 --- /dev/null +++ b/structured/src/main/java/module-info.java @@ -0,0 +1,3 @@ +module com.softwaremill.jox.structured { + exports com.softwaremill.jox.structured; +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/CancelTest.java b/structured/src/test/java/com/softwaremill/jox/structured/CancelTest.java new file mode 100644 index 0000000..b4a5e2e --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/CancelTest.java @@ -0,0 +1,119 @@ +package com.softwaremill.jox.structured; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; + +import static org.junit.jupiter.api.Assertions.*; + +public class CancelTest { + @Test + void testCancelBlocksUntilForkCompletes() throws Exception { + Trail trail = new Trail(); + Scopes.supervised(scope -> { + var f = scope.forkCancellable(() -> { + trail.add("started"); + try { + Thread.sleep(500); + trail.add("main done"); + } catch (InterruptedException e) { + trail.add("interrupted"); + Thread.sleep(500); + trail.add("interrupted done"); + throw e; + } + return null; + }); + + Thread.sleep(100); // making sure the fork starts + try { + f.cancel(); + } catch (ExecutionException e) { + // ignore + } + trail.add("cancel done"); + Thread.sleep(1000); + return null; + }); + assertIterableEquals(Arrays.asList("started", "interrupted", "interrupted done", "cancel done"), trail.get()); + } + + @Test + void testCancelBlocksUntilForkCompletesStressTest() throws Exception { + for (int i = 1; i <= 20; i++) { + Trail trail = new Trail(); + Semaphore s = new Semaphore(0); + int finalI = i; + Scopes.supervised(scope -> { + var f = scope.forkCancellable(() -> { + try { + s.acquire(); + trail.add("main done"); + } catch (InterruptedException e) { + trail.add("interrupted"); + Thread.sleep(100); + trail.add("interrupted done"); + } + return null; + }); + + if (finalI % 2 == 0) + Thread.sleep(1); // interleave immediate cancels and after the fork starts (probably) + try { + f.cancel(); + } catch (ExecutionException e) { + // ignore + } + s.release(1); // the acquire should be interrupted + trail.add("cancel done"); + Thread.sleep(100); + return null; + }); + if (trail.get().size() == 1) { + assertIterableEquals(List.of("cancel done"), trail.get()); // the fork wasn't even started + } else { + assertIterableEquals(Arrays.asList("interrupted", "interrupted done", "cancel done"), trail.get()); + } + } + } + + @Test + void testCancelNowReturnsImmediatelyAndWaitForForksWhenScopeCompletes() throws Exception { + Trail trail = new Trail(); + Scopes.supervised(scope -> { + var f = scope.forkCancellable(() -> { + try { + Thread.sleep(500); + trail.add("main done"); + } catch (InterruptedException e) { + Thread.sleep(500); + trail.add("interrupted done"); + } + return null; + }); + + Thread.sleep(100); // making sure the fork starts + f.cancelNow(); + trail.add("cancel done"); + assertIterableEquals(List.of("cancel done"), trail.get()); + return null; + }); + assertIterableEquals(Arrays.asList("cancel done", "interrupted done"), trail.get()); + } + + @Test + void testCancelNowFollowedByJoinEitherCatchesInterruptedExceptionWithWhichForkEnds() throws Exception { + assertThrows(ExecutionException.class, () -> Scopes.supervised(scope -> { + var f = scope.forkCancellable(() -> { + Thread.sleep(200); + return null; + }); + Thread.sleep(100); + f.cancelNow(); + return f.join(); + })); + } +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/ExceptionTest.java b/structured/src/test/java/com/softwaremill/jox/structured/ExceptionTest.java new file mode 100644 index 0000000..9a3f276 --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/ExceptionTest.java @@ -0,0 +1,184 @@ +package com.softwaremill.jox.structured; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; + +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ExceptionTest { + static class CustomException extends RuntimeException {} + + static class CustomException2 extends RuntimeException {} + + static class CustomException3 extends RuntimeException { + CustomException3(Exception e) { + super(e); + } + } + + @Test + void testUnsupervisedThrowsExceptionThrownByJoinedFork() throws InterruptedException { + Trail trail = new Trail(); + try { + Scopes.unsupervised(scope -> { + scope.forkUnsupervised(() -> { + throw new CustomException(); + }).join(); + return null; + }); + } catch (ExecutionException e) { + // the first EE, wraps CE, and is thrown by the join(); the second - wraps the first and is thrown by unsupervised + trail.add(e.getCause().getCause().getClass().getSimpleName()); + } + + assertIterableEquals(List.of("CustomException"), trail.get()); + } + + @Test + void testSupervisedThrowsExceptionThrownInScope() throws InterruptedException { + Trail trail = new Trail(); + try { + Scopes.supervised(scope -> { + throw new CustomException(); + }); + } catch (ExecutionException e) { + trail.add(e.getCause().getClass().getSimpleName()); + } + + assertIterableEquals(List.of("CustomException"), trail.get()); + } + + @Test + void testSupervisedThrowsExceptionThrownByFailingFork() throws InterruptedException { + Trail trail = new Trail(); + try { + Scopes.supervised(scope -> { + scope.forkUser(() -> { + throw new CustomException(); + }); + return null; + }); + } catch (ExecutionException e) { + trail.add(e.getCause().getClass().getSimpleName()); + } + + assertIterableEquals(List.of("CustomException"), trail.get()); + } + + @Test + void testSupervisedInterruptsOtherForksWhenFailureAddSuppressedInterruptedExceptions() throws InterruptedException { + Trail trail = new Trail(); + Semaphore s = new Semaphore(0); + + try { + Scopes.supervised(scope -> { + scope.forkUser(() -> { + s.acquire(); // will never complete + return null; + }); + scope.forkUser(() -> { + s.acquire(); // will never complete + return null; + }); + scope.forkUser(() -> { + Thread.sleep(100); + throw new CustomException(); + }); + return null; + }); + } catch (ExecutionException e) { + trail.add(e.getCause().getClass().getSimpleName()); + addExceptionWithSuppressedTo(trail, e); + } + + assertIterableEquals(List.of("CustomException", "ExecutionException(suppressed=InterruptedException,InterruptedException)"), trail.get()); + } + + @Test + void testSupervisedInterruptsOtherForksWhenFailureAddSuppressedCustomExceptions() throws InterruptedException { + Trail trail = new Trail(); + Semaphore s = new Semaphore(0); + + try { + Scopes.supervised(scope -> { + scope.forkUser(() -> { + try { + s.acquire(); // will never complete + } finally { + throw new CustomException2(); + } + }); + scope.forkUser(() -> { + Thread.sleep(100); + throw new CustomException(); + }); + return null; + }); + } catch (ExecutionException e) { + trail.add(e.getCause().getClass().getSimpleName()); + addExceptionWithSuppressedTo(trail, e); + } + + assertIterableEquals(List.of("CustomException", "ExecutionException(suppressed=CustomException2)"), trail.get()); + } + + @Test + void testSupervisedDoesNotAddOriginalExceptionAsSuppressed() throws InterruptedException { + Trail trail = new Trail(); + + try { + Scopes.supervised(scope -> { + var f = scope.fork(() -> { + throw new CustomException(); + }); + f.join(); + return null; + }); + } catch (ExecutionException e) { + addExceptionWithSuppressedTo(trail, e); + } + + // either join() might throw the original exception (shouldn't be suppressed), or it might be interrupted before + // throwing (should be suppressed then) + List expected1 = List.of("ExecutionException(suppressed=)"); + List expected2 = List.of("ExecutionException(suppressed=InterruptedException)"); + + assertTrue(trail.get().equals(expected1) || trail.get().equals(expected2)); + } + + @Test + void testSupervisedAddsExceptionAsSuppressedEvenIfWrapsOriginalException() throws InterruptedException { + Trail trail = new Trail(); + + try { + Scopes.supervised(scope -> { + var f = scope.fork(() -> { + throw new CustomException(); + }); + try { + f.join(); + } catch (Exception e) { + throw new CustomException3(e); + } + return null; + }); + } catch (ExecutionException e) { + trail.add(e.getCause().getClass().getSimpleName()); + addExceptionWithSuppressedTo(trail, e); + } + + assertIterableEquals(List.of("CustomException", "ExecutionException(suppressed=CustomException3)"), trail.get()); + } + + private void addExceptionWithSuppressedTo(Trail trail, Throwable e) { + String[] suppressed = new String[e.getSuppressed().length]; + for (int i = 0; i < e.getSuppressed().length; i++) { + suppressed[i] = e.getSuppressed()[i].getClass().getSimpleName(); + } + trail.add(e.getClass().getSimpleName() + "(suppressed=" + String.join(",", suppressed) + ")"); + } +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/ForkTest.java b/structured/src/test/java/com/softwaremill/jox/structured/ForkTest.java new file mode 100644 index 0000000..7f8cd7f --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/ForkTest.java @@ -0,0 +1,89 @@ +package com.softwaremill.jox.structured; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static com.softwaremill.jox.structured.Scopes.unsupervised; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +public class ForkTest { + @Test + void testRunTwoForksConcurrently() throws Exception { + var trail = new Trail(); + unsupervised(scope -> { + var f1 = scope.forkUnsupervised(() -> { + Thread.sleep(500); + trail.add("f1 complete"); + return 5; + }); + var f2 = scope.forkUnsupervised(() -> { + Thread.sleep(1000); + trail.add("f2 complete"); + return 6; + }); + trail.add("main mid"); + var result = f1.join() + f2.join(); + trail.add("result = " + result); + return null; + }); + + assertIterableEquals(Arrays.asList("main mid", "f1 complete", "f2 complete", "result = 11"), trail.get()); + } + + @Test + void testNestedForks() throws Exception { + Trail trail = new Trail(); + Scopes.unsupervised(scope -> { + var f1 = scope.forkUnsupervised(() -> { + var f2 = scope.forkUnsupervised(() -> { + try { + return 6; + } finally { + trail.add("f2 complete"); + } + }); + + try { + return 5 + f2.join(); + } finally { + trail.add("f1 complete"); + } + }); + + trail.add("result = " + f1.join()); + return null; + }); + + assertIterableEquals(Arrays.asList("f2 complete", "f1 complete", "result = 11"), trail.get()); + } + + @Test + void testInterruptChildForksWhenParentsComplete() throws Exception { + Trail trail = new Trail(); + Scopes.unsupervised(scope -> { + var f1 = scope.forkUnsupervised(() -> { + scope.forkUnsupervised(() -> { + try { + Thread.sleep(1000); + trail.add("f2 complete"); + return 6; + } catch (InterruptedException e) { + trail.add("f2 interrupted"); + throw e; + } + }); + + Thread.sleep(500); + trail.add("f1 complete"); + return 5; + }); + + trail.add("main mid"); + trail.add("result = " + f1.join()); + return null; + }); + + assertIterableEquals(Arrays.asList("main mid", "f1 complete", "result = 5", "f2 interrupted"), trail.get()); + } +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/ParTest.java b/structured/src/test/java/com/softwaremill/jox/structured/ParTest.java new file mode 100644 index 0000000..c5c7f90 --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/ParTest.java @@ -0,0 +1,106 @@ +package com.softwaremill.jox.structured; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import static com.softwaremill.jox.structured.Par.par; +import static com.softwaremill.jox.structured.Par.parLimit; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; + +public class ParTest { + @Test + void testParRunsComputationsInParallel() throws Exception { + Trail trail = new Trail(); + var result = par(List.of(() -> { + Thread.sleep(200); + trail.add("a"); + return 1; + }, () -> { + Thread.sleep(100); + trail.add("b"); + return 2; + })); + + trail.add("done"); + + assertIterableEquals(List.of(1, 2), result); + assertIterableEquals(Arrays.asList("b", "a", "done"), trail.get()); + } + + @Test + void testParInterruptsOtherComputationsIfOneFails() throws InterruptedException { + Trail trail = new Trail(); + try { + par(List.of(() -> { + Thread.sleep(200); + trail.add("par 1 done"); + return null; + }, () -> { + Thread.sleep(100); + trail.add("exception"); + throw new Exception("boom"); + })); + } catch (ExecutionException e) { + if (e.getCause().getMessage().equals("boom")) { + trail.add("catch"); + } + } + + // Checking if the forks aren't left running + Thread.sleep(300); + trail.add("all done"); + + assertIterableEquals(Arrays.asList("exception", "catch", "all done"), trail.get()); + } + + @Test + void testParLimitRunsUpToGivenNumberOfComputationsInParallel() throws Exception { + AtomicInteger running = new AtomicInteger(0); + AtomicInteger max = new AtomicInteger(0); + var result = parLimit(2, IntStream.rangeClosed(1, 9).>mapToObj(i -> () -> { + int current = running.incrementAndGet(); + max.updateAndGet(m -> Math.max(current, m)); + Thread.sleep(100); + running.decrementAndGet(); + return i * 2; + }).toList()); + + assertIterableEquals(List.of(2, 4, 6, 8, 10, 12, 14, 16, 18), result); + assertEquals(2, max.get()); + } + + @Test + void testParLimitInterruptsOtherComputationsIfOneFails() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + Trail trail = new Trail(); + try { + parLimit(2, IntStream.rangeClosed(1, 5).>mapToObj(i -> () -> { + if (counter.incrementAndGet() == 4) { + Thread.sleep(10); + trail.add("exception"); + throw new Exception("boom"); + } else { + Thread.sleep(200); + trail.add("x"); + return null; + } + }).toList()); + } catch (ExecutionException e) { + if (e.getCause().getMessage().equals("boom")) { + trail.add("catch"); + } + } + + Thread.sleep(300); + trail.add("all done"); + + assertIterableEquals(Arrays.asList("x", "x", "exception", "catch", "all done"), trail.get()); + } +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/RaceTest.java b/structured/src/test/java/com/softwaremill/jox/structured/RaceTest.java new file mode 100644 index 0000000..2a5dc97 --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/RaceTest.java @@ -0,0 +1,138 @@ +package com.softwaremill.jox.structured; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import static com.softwaremill.jox.structured.Race.race; +import static com.softwaremill.jox.structured.Race.timeout; +import static org.junit.jupiter.api.Assertions.*; + +public class RaceTest { + @Test + void testTimeoutShortCircuitsLongComputation() throws Exception { + Trail trail = new Trail(); + try { + timeout(1000, () -> { + Thread.sleep(2000); + trail.add("no timeout"); + return null; + }); + } catch (TimeoutException e) { + trail.add("timeout"); + } + + trail.add("done"); + Thread.sleep(2000); + + assertIterableEquals(Arrays.asList("timeout", "done"), trail.get()); + } + + @Test + void testTimeoutDoesNotInterruptShortComputation() throws Exception { + Trail trail = new Trail(); + try { + var r = timeout(1000, () -> { + Thread.sleep(500); + trail.add("no timeout"); + return 5; + }); + assertEquals(5, r); + trail.add("asserted"); + } catch (TimeoutException e) { + trail.add("timeout"); + } + + trail.add("done"); + Thread.sleep(2000); + + assertIterableEquals(Arrays.asList("no timeout", "asserted", "done"), trail.get()); + } + + @Test + void testRaceRacesSlowerAndFasterComputation() throws Exception { + Trail trail = new Trail(); + long start = System.currentTimeMillis(); + race(() -> { + Thread.sleep(1000); + trail.add("slow"); + return null; + }, () -> { + Thread.sleep(500); + trail.add("fast"); + return null; + }); + long end = System.currentTimeMillis(); + + Thread.sleep(1000); + assertIterableEquals(List.of("fast"), trail.get()); + assertTrue(end - start < 1000); + } + + @Test + void testRaceRacesFasterAndSlowerComputation() throws Exception { + Trail trail = new Trail(); + long start = System.currentTimeMillis(); + race(() -> { + Thread.sleep(500); + trail.add("fast"); + return null; + }, () -> { + Thread.sleep(1000); + trail.add("slow"); + return null; + }); + long end = System.currentTimeMillis(); + + Thread.sleep(1000); + assertIterableEquals(List.of("fast"), trail.get()); + assertTrue(end - start < 1000); + } + + @Test + void testRaceReturnsFirstSuccessfulComputationToComplete() throws Exception { + Trail trail = new Trail(); + long start = System.currentTimeMillis(); + race(() -> { + Thread.sleep(200); + trail.add("error"); + throw new RuntimeException("boom!"); + }, () -> { + Thread.sleep(500); + trail.add("slow"); + return null; + }, () -> { + Thread.sleep(1000); + trail.add("very slow"); + return null; + }); + long end = System.currentTimeMillis(); + + Thread.sleep(1000); + assertIterableEquals(Arrays.asList("error", "slow"), trail.get()); + assertTrue(end - start < 1000); + } + + @Test + void testRaceShouldAddOtherExceptionsAsSuppressed() { + var exception = assertThrows(ExecutionException.class, () -> { + race(() -> { + throw new RuntimeException("boom1!"); + }, () -> { + Thread.sleep(200); + throw new RuntimeException("boom2!"); + }, () -> { + Thread.sleep(200); + throw new RuntimeException("boom3!"); + }); + }); + + assertEquals("boom1!", exception.getCause().getMessage()); + assertEquals(Set.of("boom2!", "boom3!"), Arrays.stream(exception.getSuppressed()).map(Throwable::getMessage).collect(Collectors.toSet())); + } +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/SupervisedTest.java b/structured/src/test/java/com/softwaremill/jox/structured/SupervisedTest.java new file mode 100644 index 0000000..e3ba4cf --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/SupervisedTest.java @@ -0,0 +1,145 @@ +package com.softwaremill.jox.structured; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.*; + +public class SupervisedTest { + @Test + void testSupervisedWaitsUntilAllForksComplete() throws Exception { + Trail trail = new Trail(); + + int result = Scopes.supervised(scope -> { + scope.forkUser(() -> { + Thread.sleep(200); + trail.add("a"); + return null; + }); + + scope.forkUser(() -> { + Thread.sleep(100); + trail.add("b"); + return null; + }); + + return 2; + }); + + assertEquals(2, result); + trail.add("done"); + assertIterableEquals(Arrays.asList("b", "a", "done"), trail.get()); + } + + @Test + void testSupervisedOnlyWaitsUntilUserForksComplete() throws Exception { + Trail trail = new Trail(); + + int result = Scopes.supervised(scope -> { + scope.fork(() -> { + Thread.sleep(200); + trail.add("a"); + return null; + }); + + scope.forkUser(() -> { + Thread.sleep(100); + trail.add("b"); + return null; + }); + + return 2; + }); + + assertEquals(2, result); + trail.add("done"); + assertIterableEquals(Arrays.asList("b", "done"), trail.get()); + } + + @Test + void testSupervisedInterruptsOnceAnyForkEndsWithException() { + Trail trail = new Trail(); + + var exception = assertThrows(ExecutionException.class, () -> { + Scopes.supervised(scope -> { + scope.forkUser(() -> { + Thread.sleep(300); + trail.add("a"); + return null; + }); + + scope.forkUser(() -> { + Thread.sleep(200); + throw new RuntimeException("x"); + }); + + scope.forkUser(() -> { + Thread.sleep(100); + trail.add("b"); + return null; + }); + + return 2; + }); + }); + + assertEquals("x", exception.getCause().getMessage()); + trail.add("done"); + assertIterableEquals(Arrays.asList("b", "done"), trail.get()); + } + + @Test + void testSupervisedInterruptsMainBodyOnceForkEndsWithException() { + Trail trail = new Trail(); + + var exception = assertThrows(ExecutionException.class, () -> { + Scopes.supervised(scope -> { + scope.forkUser(() -> { + Thread.sleep(200); + throw new RuntimeException("x"); + }); + + Thread.sleep(300); + trail.add("a"); + return null; + }); + }); + + assertEquals("x", exception.getCause().getMessage()); + trail.add("done"); + assertIterableEquals(List.of("done"), trail.get()); + } + + @Test + void testSupervisedDoesNotInterruptIfUnsupervisedForkEndsWithException() throws Exception { + Trail trail = new Trail(); + + int result = Scopes.supervised(scope -> { + scope.forkUser(() -> { + Thread.sleep(300); + trail.add("a"); + return null; + }); + + scope.forkUnsupervised(() -> { + Thread.sleep(200); + throw new RuntimeException("x"); + }); + + scope.forkUser(() -> { + Thread.sleep(100); + trail.add("b"); + return null; + }); + + return 2; + }); + + assertEquals(2, result); + trail.add("done"); + assertIterableEquals(Arrays.asList("b", "a", "done"), trail.get()); + } +} diff --git a/structured/src/test/java/com/softwaremill/jox/structured/Trail.java b/structured/src/test/java/com/softwaremill/jox/structured/Trail.java new file mode 100644 index 0000000..33ba84d --- /dev/null +++ b/structured/src/test/java/com/softwaremill/jox/structured/Trail.java @@ -0,0 +1,17 @@ +package com.softwaremill.jox.structured; + +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class Trail { + private Queue data = new ConcurrentLinkedQueue<>(); + + public void add(String v) { + data.add(v); + } + + public List get() { + return data.stream().toList(); + } +}