diff --git a/README.md b/README.md
index 64b27ef..0e55b5b 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ client.verifyApiToken();
### Talking to Servers With and Without the v2 API
-The client works with servers that offer the v2 API, and with servers that do not, such as EventSourcingDB 1.2.0. Creating a client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client remembers the answer: from then on, it uses the v2 API if the server offers it, and the v1 API otherwise. If asking fails, e.g. because the server can not be reached, the first request fails, and the next one asks again. If the server answers a path of the v2 API with 404 or 405, e.g. after it was rolled back to a version without it, the client asks again on the next request.
+The client works with servers that offer the v2 API, and with servers that do not, such as EventSourcingDB 1.2.0. Creating a client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client remembers the answer: from then on, it uses the v2 API if the server offers it, and the v1 API otherwise. If asking fails, e.g. because the server can not be reached, the request that asked fails, and the next one asks again, unless the client still remembers an earlier answer. If the server answers a path of the v2 API with 404 or 405, e.g. after it was rolled back to a version without it, the client asks again on the next request. And if the client remembers that the server does not offer the v2 API, it asks again before it refuses a write with a recursive precondition, which only the v2 API can check, since the server may have been upgraded meanwhile. For details, see [Using Preconditions Recursively](#using-preconditions-recursively).
The v2 API reads with the HTTP method `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)), which carries a body, as `POST` does, but changes nothing. If a proxy, a load balancer, or a firewall sits in front of the database, it has to let `QUERY` through. The client does not fall back to `POST`.
@@ -205,6 +205,25 @@ var writtenEvents = client.writeEvents(
*Note that the query must return a single row with a single value, which is interpreted as a boolean.*
+#### Using Preconditions Recursively
+
+If you want the `isSubjectPristine`, `isSubjectPopulated`, or `isSubjectOnEventId` precondition to apply not only to a subject, but also to all of its nested subjects, pass `PreconditionOptions` with the `recursive` option set to `true` as the last argument:
+
+```java
+var writtenEvents = client.writeEvents(
+ List.of(
+ // ...
+ ),
+ List.of(
+ new IsSubjectPristinePrecondition("/books/42", new PreconditionOptions(true))
+ )
+);
+```
+
+The `isSubjectPristine` precondition then holds if neither the subject nor any of its nested subjects has an event, the `isSubjectPopulated` precondition holds if any of them has one, and the `isSubjectOnEventId` precondition holds if the last event among all of them has the given ID.
+
+*Note that only a server that offers the v2 API can check a recursive precondition. If the client remembers from an earlier request that the server does not offer it, it asks the server again before the write, since the server may have been upgraded meanwhile, and writes through the v2 API if the server offers it now. If the server does not offer the v2 API, the client throws an `IncompatibleServerException` without sending the write, so no events are written.*
+
#### Aborting Writing
To abort writing, e.g. if the server does not answer in time, interrupt the thread that writes. For example, run the call as a task, and cancel the task if it takes too long:
@@ -737,6 +756,8 @@ try {
}
```
+If a write has a recursive precondition, and the server does not offer the v2 API, which a recursive precondition needs, the client throws an `IncompatibleServerException` without sending the write. If the client remembers from an earlier request that the server does not offer it, it asks the server again first. For details, see [Using Preconditions Recursively](#using-preconditions-recursively).
+
If verifying an event fails, the client throws a `HashVerificationFailedException` or a `SignatureVerificationFailedException`, and if the hash of the event is of a version the client does not know, an `UnsupportedHashVersionException`. A signature that is not hexadecimal throws an `IllegalArgumentException` instead. For details, see [Verifying an Event's Hash](#verifying-an-events-hash) and [Verifying an Event's Signature](#verifying-an-events-signature).
If a request receives nothing from the server for the [stall timeout](#configuring-the-stall-timeout), 30 seconds by default, e.g. because the server can not be reached, or because a proxy keeps the connection open but no longer passes anything on, the client closes the connection, and the request ends with a `ConnectionStalledException`.
@@ -761,7 +782,7 @@ try {
}
```
-A write that certainly was not stored throws the failure itself instead: an `EventSourcingDbException` if the client could not connect to the server at all, and a `DbApiException` if the server refused the write, e.g. because a precondition does not hold, or because the client sent too many requests.
+A write that certainly was not stored throws the failure itself instead: an `EventSourcingDbException` if the client could not connect to the server at all, a `DbApiException` if the server refused the write, e.g. because a precondition does not hold, or because the client sent too many requests, an `IncompatibleServerException` if the client refused the write before sending it, because it has a recursive precondition that the server can not check, and the failure of asking the server which API it offers, if the client had to ask before sending the write and asking failed, e.g. a `DbApiException` with status `500`, a `ConnectionStalledException`, or an `InvalidServerHeaderException`.
*Note that the networking property `jdk.httpclient.enableAllMethodRetry` of the JDK, set as a system property or in the `conf/net.properties` of the JDK, makes the JDK send a write again if the connection closes before the answer arrives, which can store the write twice. If it is set when a client is created, or when a write fails, the client can not tell a write that could not connect from one that was sent again, so from then on, it throws an `OutcomeUnknownException` for that, too.*
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java
index 43293fe..3154d71 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Client.java
@@ -19,9 +19,12 @@
*
The client talks to every supported version of EventSourcingDB, whether it offers the v2 API or not. Creating a
* client sends no request. The first request of a client asks the server whether it offers the v2 API, and the client
* uses the v2 API from then on if it does, and the v1 API otherwise. If asking fails, e.g. because the server can not
- * be reached, that first request fails, and the next request asks again. If the server answers a path of the v2 API
- * with 404 or 405, e.g. after it was rolled back to a version without it, the next request asks again, too. The v2 API
- * reads with the HTTP method {@code QUERY}, so a proxy in front of the database has to let it through.
+ * be reached, the request that asked fails, and the next request asks again, unless the client still remembers an
+ * earlier answer. If the server answers a path of the v2 API with 404 or 405, e.g. after it was rolled back to a
+ * version without it, the next request asks again, too. And if the client remembers that the server does not offer the
+ * v2 API, a write with a recursive precondition, which only the v2 API can check, asks again before the client refuses
+ * it, since the server may have been upgraded meanwhile; see {@link PreconditionOptions}. The v2 API reads with the
+ * HTTP method {@code QUERY}, so a proxy in front of the database has to let it through.
*
*
Every method that sends a request throws a {@link DbApiException} if the server reports an error, e.g. a
* precondition that does not hold, and an {@link InvalidServerHeaderException} if the server is not an
@@ -106,13 +109,32 @@ Transport chosenTransport() {
}
// Learns which API the server offers, which only the first request of a
- // client asks the server for, and sends the request through it. How the
- // request fails may tell that the server does not offer that API any
- // longer.
+ // client asks the server for, and sends the request through it.
+ //
+ // The v1 API refuses a write with a recursive precondition before it is
+ // sent. The server may have been upgraded to a version with the v2 API
+ // since the client asked, so unless the write waited for a probe itself,
+ // the client asks again, and sends the write through the v2 API if the
+ // server offers it now, or refuses it through the v1 API once more
+ // otherwise.
private T send(String action, Function request) {
Http.throwIfInterrupted(action);
var choice = TransportChooser.await(action, transports.transport());
+ try {
+ return sendThrough(choice, request);
+ } catch (IncompatibleServerException ex) {
+ if (!choice.isRemembered()) {
+ throw ex;
+ }
+
+ return sendThrough(TransportChooser.await(action, transports.askAgain()), request);
+ }
+ }
+
+ // Sends the request through the transport of the choice. How the request
+ // fails may tell that the server does not offer that API any longer.
+ private T sendThrough(TransportChooser.Choice choice, Function request) {
try {
return request.apply(choice.transport());
} catch (RuntimeException ex) {
@@ -177,11 +199,11 @@ public List writeEvents(List events) {
* @throws DbApiException with the code {@link ErrorCode#PRECONDITION_VIOLATED} if a precondition does not hold, or
* {@link ErrorCode#EVENT_SCHEMA_VIOLATED} if an event does not match the schema of its type
* @throws OutcomeUnknownException if writing failed in a way that leaves open whether the events were written
+ * @throws IncompatibleServerException if a precondition is recursive, and the server does not offer the v2 API,
+ * which a recursive precondition needs, see {@link PreconditionOptions}; the events are not written then
*/
public List writeEvents(List events, List preconditions) {
- var body = Json.MAPPER.createObjectNode();
-
- var eventsNode = body.putArray("events");
+ var eventsNode = Json.MAPPER.createArrayNode();
for (var event : events) {
var eventNode = eventsNode.addObject();
eventNode.put("source", event.source());
@@ -197,31 +219,7 @@ public List writeEvents(List events, List p
}
}
- if (!preconditions.isEmpty()) {
- var preconditionsNode = body.putArray("preconditions");
- for (var precondition : preconditions) {
- preconditionsNode.add(Json.MAPPER.valueToTree(toRequest(precondition)));
- }
- }
-
- return send("write events", transport -> transport.writeEvents(body));
- }
-
- private static Map toRequest(Precondition precondition) {
- return switch (precondition) {
- case IsSubjectPristinePrecondition pristinePrecondition ->
- Map.of("type", "isSubjectPristine", "payload", Map.of("subject", pristinePrecondition.subject()));
- case IsSubjectPopulatedPrecondition populatedPrecondition ->
- Map.of("type", "isSubjectPopulated", "payload", Map.of("subject", populatedPrecondition.subject()));
- case IsSubjectOnEventIdPrecondition onEventIdPrecondition ->
- Map.of(
- "type",
- "isSubjectOnEventId",
- "payload",
- Map.of("subject", onEventIdPrecondition.subject(), "eventId", onEventIdPrecondition.eventId()));
- case IsEventQlQueryTruePrecondition queryPrecondition ->
- Map.of("type", "isEventQlQueryTrue", "payload", Map.of("query", queryPrecondition.query()));
- };
+ return send("write events", transport -> transport.writeEvents(eventsNode, preconditions));
}
/**
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IncompatibleServerException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IncompatibleServerException.java
new file mode 100644
index 0000000..0036acc
--- /dev/null
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IncompatibleServerException.java
@@ -0,0 +1,16 @@
+package io.thenativeweb.eventsourcingdb;
+
+/**
+ * Reports that the server does not offer what a request needs, so that the client does not send the request at all.
+ * {@link Client#writeEvents(java.util.List, java.util.List)} throws it for a recursive precondition, see
+ * {@link PreconditionOptions}, if the server does not offer the v2 API, which a recursive precondition needs, and
+ * writes no event then. If the client remembers from an earlier request that the server does not offer the v2 API, it
+ * asks the server again before it throws, since the server may have been upgraded meanwhile.
+ */
+public final class IncompatibleServerException extends EventSourcingDbException {
+ private static final long serialVersionUID = 1L;
+
+ IncompatibleServerException() {
+ super("the server does not offer the v2 API, which a recursive precondition needs");
+ }
+}
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectOnEventIdPrecondition.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectOnEventIdPrecondition.java
index 78e5734..7172208 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectOnEventIdPrecondition.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectOnEventIdPrecondition.java
@@ -1,10 +1,26 @@
package io.thenativeweb.eventsourcingdb;
+import org.jspecify.annotations.Nullable;
+
/**
* A precondition that holds if the last event of the given subject has the given ID, i.e. if no event was written to
- * the subject since that one.
+ * the subject since that one. If the options make it recursive, it holds if the last event among the subject and its
+ * nested subjects has the given ID.
*
* @param subject the subject, e.g. {@code /books/42}
* @param eventId the ID of the event that must be the last one of the subject
+ * @param options the options, i.e. whether the precondition applies to the nested subjects, too, or {@code null} for
+ * the subject alone
*/
-public record IsSubjectOnEventIdPrecondition(String subject, String eventId) implements Precondition {}
+public record IsSubjectOnEventIdPrecondition(
+ String subject, String eventId, @Nullable PreconditionOptions options) implements Precondition {
+ /**
+ * Creates a precondition on the subject alone.
+ *
+ * @param subject the subject, e.g. {@code /books/42}
+ * @param eventId the ID of the event that must be the last one of the subject
+ */
+ public IsSubjectOnEventIdPrecondition(String subject, String eventId) {
+ this(subject, eventId, null);
+ }
+}
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPopulatedPrecondition.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPopulatedPrecondition.java
index 1b50aee..0a13ec9 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPopulatedPrecondition.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPopulatedPrecondition.java
@@ -1,8 +1,23 @@
package io.thenativeweb.eventsourcingdb;
+import org.jspecify.annotations.Nullable;
+
/**
- * A precondition that holds if the given subject already has at least one event.
+ * A precondition that holds if the given subject already has at least one event. If the options make it recursive, it
+ * holds if the subject or any of its nested subjects has an event.
*
* @param subject the subject, e.g. {@code /books/42}
+ * @param options the options, i.e. whether the precondition applies to the nested subjects, too, or {@code null} for
+ * the subject alone
*/
-public record IsSubjectPopulatedPrecondition(String subject) implements Precondition {}
+public record IsSubjectPopulatedPrecondition(
+ String subject, @Nullable PreconditionOptions options) implements Precondition {
+ /**
+ * Creates a precondition on the subject alone.
+ *
+ * @param subject the subject, e.g. {@code /books/42}
+ */
+ public IsSubjectPopulatedPrecondition(String subject) {
+ this(subject, null);
+ }
+}
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPristinePrecondition.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPristinePrecondition.java
index c262762..b59b86a 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPristinePrecondition.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/IsSubjectPristinePrecondition.java
@@ -1,8 +1,23 @@
package io.thenativeweb.eventsourcingdb;
+import org.jspecify.annotations.Nullable;
+
/**
- * A precondition that holds if the given subject does not have any events yet.
+ * A precondition that holds if the given subject does not have any events yet. If the options make it recursive, it
+ * holds if neither the subject nor any of its nested subjects has an event.
*
* @param subject the subject, e.g. {@code /books/42}
+ * @param options the options, i.e. whether the precondition applies to the nested subjects, too, or {@code null} for
+ * the subject alone
*/
-public record IsSubjectPristinePrecondition(String subject) implements Precondition {}
+public record IsSubjectPristinePrecondition(
+ String subject, @Nullable PreconditionOptions options) implements Precondition {
+ /**
+ * Creates a precondition on the subject alone.
+ *
+ * @param subject the subject, e.g. {@code /books/42}
+ */
+ public IsSubjectPristinePrecondition(String subject) {
+ this(subject, null);
+ }
+}
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java
index 6d95668..1898a0f 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownException.java
@@ -12,8 +12,12 @@
* the request, even while the client was still connecting to the server.
*
* A write that certainly was not stored throws the failure itself instead: an {@link EventSourcingDbException} if
- * the client could not connect to the server at all, and a {@link DbApiException} if the server refused the write,
- * e.g. because a precondition does not hold.
+ * the client could not connect to the server at all, a {@link DbApiException} if the server refused the write, e.g.
+ * because a precondition does not hold, an {@link IncompatibleServerException} if the client refused the write before
+ * sending it, because it has a recursive precondition that the server can not check, and the failure of asking the
+ * server which API it offers, if the client had to ask before sending the write and asking failed, e.g. a
+ * {@link DbApiException} with status 500, a {@link ConnectionStalledException}, or an
+ * {@link InvalidServerHeaderException}.
*/
public final class OutcomeUnknownException extends EventSourcingDbException {
private static final long serialVersionUID = 1L;
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/PreconditionOptions.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/PreconditionOptions.java
new file mode 100644
index 0000000..ad167bf
--- /dev/null
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/PreconditionOptions.java
@@ -0,0 +1,19 @@
+package io.thenativeweb.eventsourcingdb;
+
+/**
+ * Options for the preconditions on a subject, i.e. {@link IsSubjectPristinePrecondition},
+ * {@link IsSubjectPopulatedPrecondition}, and {@link IsSubjectOnEventIdPrecondition}.
+ *
+ *
{@code
+ * new PreconditionOptions(true)
+ * }
+ *
+ * Only a server that offers the v2 API can check a recursive precondition. If the client remembers from an earlier
+ * request that the server does not offer it, it asks the server again before a write with a recursive precondition,
+ * since the server may have been upgraded meanwhile, and sends the write through the v2 API if the server offers it
+ * now. If the server does not offer the v2 API, a write with a recursive precondition throws an
+ * {@link IncompatibleServerException}, and writes nothing.
+ *
+ * @param recursive whether the precondition applies to the nested subjects, too, rather than the subject alone
+ */
+public record PreconditionOptions(boolean recursive) {}
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java
index 49c22ae..7efac09 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Transport.java
@@ -3,6 +3,7 @@
import java.util.List;
import java.util.function.Function;
import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
// Transport sends the requests of the client through one API of the server.
@@ -14,7 +15,10 @@ sealed interface Transport permits V1Transport, V2Transport {
void verifyApiToken();
- List writeEvents(ObjectNode body);
+ // The APIs take the preconditions of a write in shapes of their own, see
+ // WriteEventsBody, so the client hands them over as they are, along with
+ // the events, which both APIs take alike.
+ List writeEvents(ArrayNode events, List preconditions);
StreamSpec readEvents(ObjectNode body);
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java
index 7dc0430..4b78213 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/TransportChooser.java
@@ -23,13 +23,21 @@
// The answer is kept for the client, so the server is asked only once.
//
// Any other outcome, e.g. a server that can not be reached, tells nothing, so
-// the request that asked fails, and the next request asks again. Requests
-// that are made while the server is being asked wait for its answer rather
-// than asking again, and fail along with it, each with its own action.
+// the request that asked fails, and the next request asks again, unless the
+// client still keeps an earlier answer. Requests that are made while the
+// server is being asked, and that find no answer kept, wait for its answer
+// rather than asking again, and fail along with it, each with its own action.
//
// A server that answers a request through the v2 API with 404 or 405 does not
// offer it any longer, e.g. because it was rolled back to a version without
// it, so the client forgets the answer, and the next request asks again.
+//
+// A server that did not offer the v2 API when the client asked may have been
+// upgraded to a version with it since. Only a write with a recursive
+// precondition needs the v2 API, so such a write asks again before the client
+// refuses it, unless the write waited for a probe itself. Every other request
+// keeps the answer meanwhile, which stays if asking again fails, and is
+// replaced once the server answers.
final class TransportChooser {
private static final String PROBE_PATH = "/api/v2/system/ping";
@@ -46,8 +54,10 @@ final class TransportChooser {
// generation of that answer, which counts the answers that probes stored.
// The generation tells an answer apart from a later one that names the
// same transport, so that a request can tell which answer it was sent
- // under.
- record Choice(Transport transport, long generation) {}
+ // under. A request either finds the answer remembered from an earlier
+ // request, which may be out of date by now, or waits for the probe that
+ // finds it out.
+ record Choice(Transport transport, long generation, boolean isRemembered) {}
TransportChooser(Requests requests, V1Transport v1, V2Transport v2) {
this.requests = requests;
@@ -66,12 +76,7 @@ CompletableFuture transport() {
return CompletableFuture.completedFuture(chosen);
}
- var probe = this.probe;
- if (probe == null) {
- probe = startProbe();
- }
-
- return probe.copy();
+ return startOrJoinProbe();
}
}
@@ -112,6 +117,29 @@ void forgetIfTheV2ApiIsMissing(Choice choice, RuntimeException failure) {
}
}
+ // Asks the server again which API it offers, and returns the choice once
+ // the server answers. The answer stays kept meanwhile, so that every other
+ // request keeps using it rather than waiting, and so that it stays if
+ // asking fails. A request that asks again while a probe is running shares
+ // that probe.
+ CompletableFuture askAgain() {
+ synchronized (lock) {
+ return startOrJoinProbe();
+ }
+ }
+
+ // Starts a probe, unless one is running already, and returns a future of
+ // its answer that is the caller's own, so that canceling it ends only the
+ // wait of the caller. It is called under the lock, as startProbe is.
+ private CompletableFuture startOrJoinProbe() {
+ var probe = this.probe;
+ if (probe == null) {
+ probe = startProbe();
+ }
+
+ return probe.copy();
+ }
+
// Tells whether a request failed because the server answered it with a
// status code that says that it does not offer the v2 API.
private static boolean isAnsweredWithoutTheV2Api(@Nullable Throwable answer) {
@@ -171,10 +199,11 @@ private CompletableFuture startProbe() {
}
// Keeps the transport that the probe found out, as an answer of a
- // generation of its own, or forgets the probe if it failed, so that the
- // next request probes again. Either way, a probe is running no longer. A
- // probe starts only while no other is running, so the probe that
- // completes is the one that is kept.
+ // generation of its own, in place of the one kept before, if any, or
+ // forgets the probe if it failed, which leaves the answer kept before, so
+ // that the next request probes again only if there is none. Either way, a
+ // probe is running no longer. A probe starts only while no other is
+ // running, so the probe that completes is the one that is kept.
private void complete(
CompletableFuture result,
@Nullable HttpResponse answer,
@@ -191,14 +220,14 @@ private void complete(
return;
}
- Choice choice;
+ long answerGeneration;
synchronized (lock) {
generation++;
- choice = new Choice(transport, generation);
- chosen = choice;
+ answerGeneration = generation;
+ chosen = new Choice(transport, answerGeneration, true);
probe = null;
}
- result.complete(choice);
+ result.complete(new Choice(transport, answerGeneration, false));
}
private Transport interpret(
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java
index db77e3f..6427144 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V1Transport.java
@@ -6,6 +6,7 @@
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
// V1Transport sends the requests of the client through the v1 API, for a
@@ -44,8 +45,18 @@ public void verifyApiToken() {
}
}
+ // The v1 API knows no options of preconditions. A precondition that is
+ // not recursive means the same without them, but a recursive one can not
+ // be checked at all, so the write fails before it is sent, rather than
+ // checking less than it asks for.
@Override
- public List writeEvents(ObjectNode body) {
+ public List writeEvents(ArrayNode events, List preconditions) {
+ if (preconditions.stream().anyMatch(WriteEventsBody::isRecursive)) {
+ throw new IncompatibleServerException();
+ }
+
+ var body = WriteEventsBody.withoutPreconditionOptions(events, preconditions);
+
return requests.write(
"write events",
requests.post("/api/v1/write-events", body),
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java
index 0d83885..a45b936 100644
--- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/V2Transport.java
@@ -6,6 +6,7 @@
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
// V2Transport sends the requests of the client through the v2 API. It reads
@@ -54,8 +55,9 @@ public void verifyApiToken() {
}
@Override
- public List writeEvents(ObjectNode body) {
+ public List writeEvents(ArrayNode events, List preconditions) {
var action = "write events";
+ var body = WriteEventsBody.withPreconditionOptions(events, preconditions);
return requests.write(
action,
diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/WriteEventsBody.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/WriteEventsBody.java
new file mode 100644
index 0000000..be37bc5
--- /dev/null
+++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/WriteEventsBody.java
@@ -0,0 +1,86 @@
+package io.thenativeweb.eventsourcingdb;
+
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+import tools.jackson.databind.node.ArrayNode;
+import tools.jackson.databind.node.ObjectNode;
+
+// WriteEventsBody builds the body of a write, which both APIs take alike, but
+// for the options of the preconditions on a subject, which only the v2 API
+// knows. A precondition is sent with options only if it has any, since a
+// server that offers the v2 API, but does not know the options yet, refuses
+// them as a field it does not know.
+final class WriteEventsBody {
+ private WriteEventsBody() {}
+
+ static ObjectNode withPreconditionOptions(ArrayNode events, List preconditions) {
+ return of(events, preconditions, true);
+ }
+
+ static ObjectNode withoutPreconditionOptions(ArrayNode events, List preconditions) {
+ return of(events, preconditions, false);
+ }
+
+ // Tells whether the options of a precondition make it apply to the
+ // nested subjects of its subject, too.
+ static boolean isRecursive(Precondition precondition) {
+ var options = optionsOf(precondition);
+ return options != null && options.recursive();
+ }
+
+ private static ObjectNode of(ArrayNode events, List preconditions, boolean isSendingOptions) {
+ var body = Json.MAPPER.createObjectNode();
+ body.set("events", events);
+
+ if (!preconditions.isEmpty()) {
+ var preconditionsNode = body.putArray("preconditions");
+ for (var precondition : preconditions) {
+ preconditionsNode.add(toRequest(precondition, isSendingOptions));
+ }
+ }
+
+ return body;
+ }
+
+ private static ObjectNode toRequest(Precondition precondition, boolean isSendingOptions) {
+ var node = Json.MAPPER.createObjectNode();
+ var payload = Json.MAPPER.createObjectNode();
+
+ switch (precondition) {
+ case IsSubjectPristinePrecondition pristinePrecondition -> {
+ node.put("type", "isSubjectPristine");
+ payload.put("subject", pristinePrecondition.subject());
+ }
+ case IsSubjectPopulatedPrecondition populatedPrecondition -> {
+ node.put("type", "isSubjectPopulated");
+ payload.put("subject", populatedPrecondition.subject());
+ }
+ case IsSubjectOnEventIdPrecondition onEventIdPrecondition -> {
+ node.put("type", "isSubjectOnEventId");
+ payload.put("subject", onEventIdPrecondition.subject());
+ payload.put("eventId", onEventIdPrecondition.eventId());
+ }
+ case IsEventQlQueryTruePrecondition queryPrecondition -> {
+ node.put("type", "isEventQlQueryTrue");
+ payload.put("query", queryPrecondition.query());
+ }
+ }
+
+ var options = optionsOf(precondition);
+ if (isSendingOptions && options != null) {
+ payload.putObject("options").put("recursive", options.recursive());
+ }
+
+ node.set("payload", payload);
+ return node;
+ }
+
+ private static @Nullable PreconditionOptions optionsOf(Precondition precondition) {
+ return switch (precondition) {
+ case IsSubjectPristinePrecondition pristinePrecondition -> pristinePrecondition.options();
+ case IsSubjectPopulatedPrecondition populatedPrecondition -> populatedPrecondition.options();
+ case IsSubjectOnEventIdPrecondition onEventIdPrecondition -> onEventIdPrecondition.options();
+ case IsEventQlQueryTruePrecondition queryPrecondition -> null;
+ };
+ }
+}
diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PreconditionOptionsTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PreconditionOptionsTest.java
new file mode 100644
index 0000000..73a9b57
--- /dev/null
+++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/PreconditionOptionsTest.java
@@ -0,0 +1,162 @@
+package io.thenativeweb.eventsourcingdb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import tools.jackson.databind.JsonNode;
+
+// The v2 API applies a precondition on a subject to the nested subjects, too,
+// if its options say so, so the client sends the options of a precondition
+// that has them, and a precondition without options as it is. The v1 API knows
+// no options: the client sends a precondition that is not recursive without
+// them, and refuses to write with a recursive one, without sending the write.
+class PreconditionOptionsTest {
+ private static final List EVENTS =
+ List.of(new EventCandidate("https://x", "/a", "io.x.y", Map.of()));
+
+ private static final String EVENTS_BODY =
+ "\"events\":[{\"source\":\"https://x\",\"subject\":\"/a\",\"type\":\"io.x.y\",\"data\":{}}]";
+
+ // Sends the write to a server that refuses it, and returns the body of the
+ // write as the server received it. The body is compared as JSON, since
+ // the order of the fields means nothing to the server.
+ private static JsonNode bodyOf(ApiVersion api, List preconditions) {
+ try (var server = FakeServer.respondingWith(api, 500, "refused")) {
+ try {
+ new Client(server.url(), "secret").writeEvents(EVENTS, preconditions);
+ } catch (OutcomeUnknownException ex) {
+ // The server refuses every write, so that its body is all that
+ // the test looks at.
+ }
+
+ return Json.MAPPER.readTree(server.requests().getLast().body());
+ }
+ }
+
+ private static JsonNode bodyWith(String precondition) {
+ return Json.MAPPER.readTree("{" + EVENTS_BODY + ",\"preconditions\":[" + precondition + "]}");
+ }
+
+ static Stream preconditionsWithOptions() {
+ return Stream.of(true, false).flatMap(recursive -> {
+ var options = new PreconditionOptions(recursive);
+ var optionsBody = ",\"options\":{\"recursive\":" + recursive + "}";
+
+ return Stream.of(
+ Arguments.of(
+ new IsSubjectPristinePrecondition("/a", options),
+ "{\"type\":\"isSubjectPristine\",\"payload\":{\"subject\":\"/a\"" + optionsBody + "}}"),
+ Arguments.of(
+ new IsSubjectPopulatedPrecondition("/a", options),
+ "{\"type\":\"isSubjectPopulated\",\"payload\":{\"subject\":\"/a\"" + optionsBody + "}}"),
+ Arguments.of(
+ new IsSubjectOnEventIdPrecondition("/a", "0", options),
+ "{\"type\":\"isSubjectOnEventId\",\"payload\":{\"subject\":\"/a\",\"eventId\":\"0\""
+ + optionsBody
+ + "}}"));
+ });
+ }
+
+ static Stream preconditionsWithoutOptions() {
+ return Stream.of(
+ Arguments.of(
+ new IsSubjectPristinePrecondition("/a"),
+ "{\"type\":\"isSubjectPristine\",\"payload\":{\"subject\":\"/a\"}}"),
+ Arguments.of(
+ new IsSubjectPopulatedPrecondition("/a"),
+ "{\"type\":\"isSubjectPopulated\",\"payload\":{\"subject\":\"/a\"}}"),
+ Arguments.of(
+ new IsSubjectOnEventIdPrecondition("/a", "0"),
+ "{\"type\":\"isSubjectOnEventId\",\"payload\":{\"subject\":\"/a\",\"eventId\":\"0\"}}"),
+ Arguments.of(
+ new IsEventQlQueryTruePrecondition("FROM e IN events PROJECT INTO COUNT() == 0"),
+ "{\"type\":\"isEventQlQueryTrue\","
+ + "\"payload\":{\"query\":\"FROM e IN events PROJECT INTO COUNT() == 0\"}}"));
+ }
+
+ // The preconditions on a subject that are not recursive, with options that
+ // say so, and the body that the v1 API takes for them, which is the one
+ // for the same precondition without options.
+ static Stream preconditionsThatAreNotRecursive() {
+ var options = new PreconditionOptions(false);
+
+ return Stream.of(
+ Arguments.of(
+ new IsSubjectPristinePrecondition("/a", options),
+ "{\"type\":\"isSubjectPristine\",\"payload\":{\"subject\":\"/a\"}}"),
+ Arguments.of(
+ new IsSubjectPopulatedPrecondition("/a", options),
+ "{\"type\":\"isSubjectPopulated\",\"payload\":{\"subject\":\"/a\"}}"),
+ Arguments.of(
+ new IsSubjectOnEventIdPrecondition("/a", "0", options),
+ "{\"type\":\"isSubjectOnEventId\",\"payload\":{\"subject\":\"/a\",\"eventId\":\"0\"}}"));
+ }
+
+ static Stream recursivePreconditions() {
+ var options = new PreconditionOptions(true);
+
+ return Stream.of(
+ new IsSubjectPristinePrecondition("/a", options),
+ new IsSubjectPopulatedPrecondition("/a", options),
+ new IsSubjectOnEventIdPrecondition("/a", "0", options));
+ }
+
+ @ParameterizedTest
+ @MethodSource("preconditionsWithOptions")
+ void sendsTheOptionsOfAPreconditionToTheV2Api(Precondition precondition, String expected) {
+ assertEquals(bodyWith(expected), bodyOf(ApiVersion.V2, List.of(precondition)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("preconditionsWithoutOptions")
+ void sendsAPreconditionWithoutOptionsToEitherApiAsItIs(Precondition precondition, String expected) {
+ for (var api : ApiVersion.values()) {
+ assertEquals(bodyWith(expected), bodyOf(api, List.of(precondition)), api.name());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("preconditionsThatAreNotRecursive")
+ void sendsAPreconditionThatIsNotRecursiveToTheV1ApiWithoutItsOptions(Precondition precondition, String expected) {
+ assertEquals(bodyWith(expected), bodyOf(ApiVersion.V1, List.of(precondition)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("recursivePreconditions")
+ void refusesToWriteWithARecursivePreconditionIfTheServerDoesNotOfferTheV2Api(Precondition precondition) {
+ try (var server = FakeServer.respondingWith(ApiVersion.V1, 200, "[]")) {
+ var client = new Client(server.url(), "secret");
+
+ var exception = assertThrows(
+ IncompatibleServerException.class, () -> client.writeEvents(EVENTS, List.of(precondition)));
+
+ assertEquals(
+ "the server does not offer the v2 API, which a recursive precondition needs",
+ exception.getMessage());
+ assertEquals(0, server.requestCount());
+ }
+ }
+
+ // A recursive precondition among others is refused all the same, rather
+ // than the others being checked without it.
+ @Test
+ void refusesToWriteWithARecursivePreconditionAmongOthersIfTheServerDoesNotOfferTheV2Api() {
+ try (var server = FakeServer.respondingWith(ApiVersion.V1, 200, "[]")) {
+ var client = new Client(server.url(), "secret");
+ var preconditions = List.of(
+ new IsEventQlQueryTruePrecondition("FROM e IN events PROJECT INTO COUNT() == 0"),
+ new IsSubjectPristinePrecondition("/a", new PreconditionOptions(false)),
+ new IsSubjectPopulatedPrecondition("/b", new PreconditionOptions(true)));
+
+ assertThrows(IncompatibleServerException.class, () -> client.writeEvents(EVENTS, preconditions));
+ assertEquals(0, server.requestCount());
+ }
+ }
+}
diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java
index 12e1494..cf40904 100644
--- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java
+++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/ProbeTest.java
@@ -6,6 +6,8 @@
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import com.sun.net.httpserver.HttpExchange;
import io.thenativeweb.eventsourcingdb.Operations.Operation;
@@ -33,12 +35,16 @@
// The first request of a client asks the server whether it offers the v2 API,
// by pinging the v2 API. The client keeps a definitive answer, and asks again
-// after any other, and once the server answers a request through the v2 API
-// with 404 or 405.
+// after any other, unless it still keeps an earlier answer, once the server
+// answers a request through the v2 API with 404 or 405, and before it refuses
+// a write with a recursive precondition because it remembers that the server
+// does not offer the v2 API, unless the write waited for a probe itself.
@Timeout(10)
class ProbeTest {
private static final Duration STALL_TIMEOUT = Duration.ofMillis(300);
+ private static final Duration WAITING_DEADLINE = Duration.ofSeconds(5);
+
private static final String V1_PING_RECEIVED = "{\"type\":\"io.eventsourcingdb.api.ping-received\"}";
private static final String V1_API_TOKEN_VERIFIED = "{\"type\":\"io.eventsourcingdb.api.api-token-verified\"}";
@@ -306,9 +312,23 @@ void reportsAWriteWhoseProbeFailsAsNotStored() {
}
// Waits until each of the threads waits, which they do only once they
- // wait for the probe.
+ // wait for the probe. The loop spins, which the timeout of the test can
+ // not interrupt, so a thread that ended without waiting for the probe
+ // fails the test at once, and one that does not get to wait for it fails
+ // the test once the deadline has passed, which is generous compared with
+ // the moment it takes a request to start waiting.
private static void awaitWaiting(List threads) {
+ var deadline = System.nanoTime() + WAITING_DEADLINE.toNanos();
+
while (threads.stream().anyMatch(thread -> thread.getState() != Thread.State.WAITING)) {
+ if (threads.stream().anyMatch(thread -> thread.getState() == Thread.State.TERMINATED)) {
+ fail("a request ended without waiting for the probe");
+ }
+ if (System.nanoTime() - deadline > 0) {
+ fail("the requests did not all wait for the probe within " + Durations.describe(WAITING_DEADLINE)
+ + ", their threads are "
+ + threads.stream().map(Thread::getState).toList());
+ }
Thread.onSpinWait();
}
}
@@ -704,4 +724,199 @@ void aLate404LeavesTheV2ApiThatANewProbeFoundOutAlone() throws InterruptedExcept
assertEquals(2, server.probeCount());
}
}
+
+ private static final List EVENTS =
+ List.of(new EventCandidate("https://x", "/a", "io.x.y", Map.of()));
+
+ private static final List RECURSIVE_PRECONDITIONS =
+ List.of(new IsSubjectPristinePrecondition("/a", new PreconditionOptions(true)));
+
+ private static final String WRITE_PATH_OF_THE_V2_API = "/api/v2/data/write-events";
+
+ // A server that was upgraded to a version with the v2 API answers a write
+ // and the verification of an API token through the v2 API, and the v1 API
+ // as it did before.
+ private static final ProbedServer.Answer UPGRADED = (exchange, server) -> {
+ switch (exchange.getRequestURI().getPath()) {
+ case WRITE_PATH_OF_THE_V2_API ->
+ FakeServer.respond(exchange, 200, Lines.event(ApiVersion.V2) + "\n" + ApiVersion.V2.end() + "\n");
+ case "/api/v2/system/verify-token" ->
+ FakeServer.respond(
+ exchange,
+ 200,
+ ApiVersion.V2.item("event", "{\"type\":\"io.eventsourcingdb.api.token-verified\"}"));
+ default -> ProbedServer.answerAsV1(exchange, server);
+ }
+ };
+
+ private static long writeCountOfTheV2Api(ProbedServer server) {
+ return server.paths().stream()
+ .filter(path -> path.equals(WRITE_PATH_OF_THE_V2_API))
+ .count();
+ }
+
+ // A server that did not offer the v2 API when the client asked, and was
+ // upgraded to a version with it afterwards, can check a recursive
+ // precondition. So before the client refuses a write with one, it asks
+ // again, sends the write through the v2 API if the server offers it now,
+ // and keeps the v2 API for the requests that follow.
+ @Test
+ void asksAgainBeforeRefusingARecursivePreconditionAndWritesThroughTheV2ApiIfTheServerOffersItNow() {
+ try (var server = new ProbedServer(UPGRADED, List.of(V1, V2))) {
+ var client = server.client();
+ client.ping();
+
+ var events = client.writeEvents(EVENTS, RECURSIVE_PRECONDITIONS);
+ client.verifyApiToken();
+
+ assertEquals(1, events.size());
+ assertEquals(
+ List.of(
+ FakeServer.PROBE_PATH,
+ "/api/v1/ping",
+ FakeServer.PROBE_PATH,
+ WRITE_PATH_OF_THE_V2_API,
+ "/api/v2/system/verify-token"),
+ server.paths());
+
+ var write = server.server.requests().stream()
+ .filter(request -> request.path().equals(WRITE_PATH_OF_THE_V2_API))
+ .findFirst()
+ .orElseThrow();
+ assertTrue(Json.MAPPER
+ .readTree(write.body())
+ .path("preconditions")
+ .path(0)
+ .path("payload")
+ .path("options")
+ .path("recursive")
+ .asBoolean());
+ }
+ }
+
+ @Test
+ void refusesARecursivePreconditionIfTheServerStillDoesNotOfferTheV2ApiWhenAskedAgain() {
+ try (var server = new ProbedServer(V1)) {
+ var client = server.client();
+ client.ping();
+
+ assertThrows(IncompatibleServerException.class, () -> client.writeEvents(EVENTS, RECURSIVE_PRECONDITIONS));
+
+ assertEquals(List.of(FakeServer.PROBE_PATH, "/api/v1/ping", FakeServer.PROBE_PATH), server.paths());
+ }
+ }
+
+ // The first request of a client has just asked which API the server
+ // offers, so a write with a recursive precondition does not ask twice.
+ @Test
+ void doesNotAskAgainForARecursivePreconditionOnTheFirstRequest() {
+ try (var server = new ProbedServer(V1)) {
+ var client = server.client();
+
+ assertThrows(IncompatibleServerException.class, () -> client.writeEvents(EVENTS, RECURSIVE_PRECONDITIONS));
+
+ assertEquals(List.of(FakeServer.PROBE_PATH), server.paths());
+ }
+ }
+
+ // Writes with a recursive precondition that are made at the same time
+ // share the probe that asks again, as other requests share a probe, and
+ // each of them is sent once at most.
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void sharesTheProbeThatAsksAgainBetweenWritesWithARecursivePrecondition(boolean isUpgraded)
+ throws InterruptedException {
+ var release = new CountDownLatch(1);
+ var answerAfterTheUpgrade = isUpgraded ? V2 : V1;
+
+ try (var server = new ProbedServer(UPGRADED, List.of(V1, (exchange, fakeServer) -> {
+ release.await();
+ answerAfterTheUpgrade.answer(exchange, fakeServer);
+ }))) {
+ var client = server.client();
+ client.ping();
+
+ Runnable write = () -> client.writeEvents(EVENTS, RECURSIVE_PRECONDITIONS);
+ var outcomes = runWhileTheProbeWaits(release, threads -> {}, List.of(write, write));
+
+ assertEquals(2, server.probeCount());
+ if (isUpgraded) {
+ assertEquals(Map.of(0, Optional.empty(), 1, Optional.empty()), outcomes);
+ assertEquals(2, writeCountOfTheV2Api(server));
+ } else {
+ assertInstanceOf(
+ IncompatibleServerException.class, outcomes.get(0).orElseThrow());
+ assertInstanceOf(
+ IncompatibleServerException.class, outcomes.get(1).orElseThrow());
+ assertEquals(0, writeCountOfTheV2Api(server));
+ }
+ }
+ }
+
+ // While the client asks again for a write with a recursive precondition,
+ // every other request keeps using the v1 API that the client remembers,
+ // rather than waiting for the answer, and once the server answers that it
+ // offers the v2 API now, the requests that follow use the v2 API.
+ @Test
+ void keepsUsingTheRememberedV1ApiForOtherRequestsWhileAskingAgain() throws InterruptedException {
+ var asked = new CountDownLatch(1);
+ var release = new CountDownLatch(1);
+ ProbedServer.Answer heldUntilReleased = (exchange, fakeServer) -> {
+ asked.countDown();
+ release.await();
+ V2.answer(exchange, fakeServer);
+ };
+
+ try (var server = new ProbedServer(UPGRADED, List.of(V1, heldUntilReleased))) {
+ // The client's own stall timeout leaves the held probe all the
+ // time it needs.
+ var client = new Client(server.server.url(), "secret");
+ client.ping();
+
+ var outcomes = new ConcurrentHashMap>();
+ var write = start("write", () -> client.writeEvents(EVENTS, RECURSIVE_PRECONDITIONS), outcomes);
+ asked.await();
+
+ try {
+ var verifying = start("verify", client::verifyApiToken, outcomes);
+ if (!verifying.join(Duration.ofSeconds(5))) {
+ fail("a request without a recursive precondition waits for the client to ask again");
+ }
+
+ assertEquals(Optional.empty(), outcomes.get("verify"));
+ assertEquals("/api/v1/verify-api-token", server.paths().getLast());
+ } finally {
+ release.countDown();
+ }
+
+ write.join();
+ client.verifyApiToken();
+
+ assertEquals(Optional.empty(), outcomes.get("write"));
+ assertEquals(1, writeCountOfTheV2Api(server));
+ assertEquals("/api/v2/system/verify-token", server.paths().getLast());
+ assertEquals(2, server.probeCount());
+ }
+ }
+
+ // Asking again tells nothing if it fails, so the write that asked fails
+ // with that failure, as any request whose probe fails, and the client
+ // keeps the v1 API for the requests that follow, without asking again.
+ @Test
+ void keepsTheRememberedV1ApiIfAskingAgainFails() {
+ try (var server = new ProbedServer(UPGRADED, List.of(V1, status(500)))) {
+ var client = server.client();
+ client.ping();
+
+ var exception =
+ assertThrows(DbApiException.class, () -> client.writeEvents(EVENTS, RECURSIVE_PRECONDITIONS));
+ client.verifyApiToken();
+
+ assertEquals("write events", exception.action());
+ assertEquals(500, exception.status());
+ assertEquals(
+ List.of(FakeServer.PROBE_PATH, "/api/v1/ping", FakeServer.PROBE_PATH, "/api/v1/verify-api-token"),
+ server.paths());
+ }
+ }
}
diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java
index 07aece8..a1291c8 100644
--- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java
+++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/WriteEventsTest.java
@@ -33,8 +33,45 @@ void stopContainer() {
}
private static EventCandidate candidate(int value) {
+ return candidate("/test", value);
+ }
+
+ private static EventCandidate candidate(String subject, int value) {
return new EventCandidate(
- "https://www.eventsourcingdb.io", "/test", "io.eventsourcingdb.test", new EventData(value));
+ "https://www.eventsourcingdb.io", subject, "io.eventsourcingdb.test", new EventData(value));
+ }
+
+ private void assertHolds(Precondition precondition) {
+ var writtenEvents = client.writeEvents(List.of(candidate(42)), List.of(precondition));
+ assertEquals(1, writtenEvents.size());
+ }
+
+ private void assertViolated(Precondition precondition) {
+ var exception = assertThrows(
+ DbApiException.class, () -> client.writeEvents(List.of(candidate(42)), List.of(precondition)));
+ assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code());
+ }
+
+ // A server without the v2 API can not check a recursive precondition, so
+ // the client refuses the write without sending it. The tests hand over a
+ // precondition that holds on the subject alone, so that a write sent
+ // without the options would be stored.
+ private void assertRefusedWithoutTheV2Api(Precondition precondition) {
+ var eventCount = countEvents();
+
+ var exception = assertThrows(
+ IncompatibleServerException.class,
+ () -> client.writeEvents(List.of(candidate(42)), List.of(precondition)));
+
+ assertEquals(
+ "the server does not offer the v2 API, which a recursive precondition needs", exception.getMessage());
+ assertEquals(eventCount, countEvents());
+ }
+
+ private long countEvents() {
+ try (var events = client.readEvents("/", new ReadEventsOptions(true))) {
+ return events.count();
+ }
}
@Test
@@ -140,6 +177,83 @@ void supportsTheIsSubjectOnEventIdPrecondition() {
assertEquals(1, writtenEvents.size());
}
+ @Test
+ void supportsTheIsSubjectPristinePreconditionRecursively() {
+ var recursive = new PreconditionOptions(true);
+ client.writeEvents(List.of(candidate("/test/1", 23)));
+
+ // The subject has no events of its own, but a nested subject has.
+ var precondition = new IsSubjectPristinePrecondition("/test", recursive);
+
+ if (Database.offersV2Api(container)) {
+ assertViolated(precondition);
+ assertHolds(new IsSubjectPristinePrecondition("/test/2", recursive));
+ } else {
+ assertRefusedWithoutTheV2Api(precondition);
+ }
+ }
+
+ @Test
+ void supportsTheIsSubjectPopulatedPreconditionRecursively() {
+ var recursive = new PreconditionOptions(true);
+ client.writeEvents(List.of(candidate("/test/1", 23)));
+
+ if (Database.offersV2Api(container)) {
+ assertViolated(new IsSubjectPopulatedPrecondition("/test/2", recursive));
+
+ // The subject has no events of its own, but a nested subject has.
+ assertHolds(new IsSubjectPopulatedPrecondition("/test", recursive));
+ } else {
+ assertRefusedWithoutTheV2Api(new IsSubjectPopulatedPrecondition("/test/1", recursive));
+ }
+ }
+
+ @Test
+ void supportsTheIsSubjectOnEventIdPreconditionRecursively() {
+ var recursive = new PreconditionOptions(true);
+ client.writeEvents(List.of(candidate("/test", 23)));
+ client.writeEvents(List.of(candidate("/test/1", 42)));
+
+ // The last event of the subject itself is 0, but the last event among
+ // the subject and its nested subjects is 1.
+ var precondition = new IsSubjectOnEventIdPrecondition("/test", "0", recursive);
+
+ if (Database.offersV2Api(container)) {
+ assertViolated(precondition);
+ assertHolds(new IsSubjectOnEventIdPrecondition("/test", "1", recursive));
+ } else {
+ assertRefusedWithoutTheV2Api(precondition);
+ }
+ }
+
+ @Test
+ void supportsTheIsSubjectPristinePreconditionWithOptionsThatAreNotRecursive() {
+ var notRecursive = new PreconditionOptions(false);
+ client.writeEvents(List.of(candidate("/test/1", 23)));
+
+ assertHolds(new IsSubjectPristinePrecondition("/test", notRecursive));
+ assertViolated(new IsSubjectPristinePrecondition("/test", notRecursive));
+ }
+
+ @Test
+ void supportsTheIsSubjectPopulatedPreconditionWithOptionsThatAreNotRecursive() {
+ var notRecursive = new PreconditionOptions(false);
+ client.writeEvents(List.of(candidate("/test/1", 23)));
+
+ assertViolated(new IsSubjectPopulatedPrecondition("/test", notRecursive));
+ assertHolds(new IsSubjectPopulatedPrecondition("/test/1", notRecursive));
+ }
+
+ @Test
+ void supportsTheIsSubjectOnEventIdPreconditionWithOptionsThatAreNotRecursive() {
+ var notRecursive = new PreconditionOptions(false);
+ client.writeEvents(List.of(candidate("/test", 23)));
+ client.writeEvents(List.of(candidate("/test/1", 42)));
+
+ assertViolated(new IsSubjectOnEventIdPrecondition("/test", "1", notRecursive));
+ assertHolds(new IsSubjectOnEventIdPrecondition("/test", "0", notRecursive));
+ }
+
@Test
void supportsTheIsEventQlQueryTruePrecondition() {
client.writeEvents(List.of(candidate(23)));