Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`.
Expand All @@ -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.*

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
* <p>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.
*
* <p>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
Expand Down Expand Up @@ -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 extends @Nullable Object> T send(String action, Function<Transport, T> 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 extends @Nullable Object> T sendThrough(TransportChooser.Choice choice, Function<Transport, T> request) {
try {
return request.apply(choice.transport());
} catch (RuntimeException ex) {
Expand Down Expand Up @@ -177,11 +199,11 @@ public List<Event> writeEvents(List<EventCandidate> 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<Event> writeEvents(List<EventCandidate> events, List<Precondition> 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());
Expand All @@ -197,31 +219,7 @@ public List<Event> writeEvents(List<EventCandidate> events, List<Precondition> 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<String, Object> 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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@
* the request, even while the client was still connecting to the server.
*
* <p>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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.thenativeweb.eventsourcingdb;

/**
* Options for the preconditions on a subject, i.e. {@link IsSubjectPristinePrecondition},
* {@link IsSubjectPopulatedPrecondition}, and {@link IsSubjectOnEventIdPrecondition}.
*
* <pre>{@code
* new PreconditionOptions(true)
* }</pre>
*
* <p>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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -14,7 +15,10 @@ sealed interface Transport permits V1Transport, V2Transport {

void verifyApiToken();

List<Event> 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<Event> writeEvents(ArrayNode events, List<Precondition> preconditions);

StreamSpec<Event> readEvents(ObjectNode body);

Expand Down
Loading
Loading