/*
* Copyright (c) 2010-2016. Axon Framework
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.eventsourcing.eventstore.jdbc;
import org.axonframework.common.Assert;
import org.axonframework.common.jdbc.ConnectionProvider;
import org.axonframework.common.jdbc.PersistenceExceptionResolver;
import org.axonframework.common.transaction.Transaction;
import org.axonframework.common.transaction.TransactionManager;
import org.axonframework.eventhandling.EventMessage;
import org.axonframework.eventsourcing.DomainEventMessage;
import org.axonframework.eventsourcing.eventstore.*;
import org.axonframework.serialization.SerializedObject;
import org.axonframework.serialization.Serializer;
import org.axonframework.serialization.upcasting.event.EventUpcaster;
import org.axonframework.serialization.xml.XStreamSerializer;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import static java.lang.String.format;
import static java.util.stream.Collectors.toSet;
import static org.axonframework.common.ObjectUtils.getOrDefault;
import static org.axonframework.common.jdbc.JdbcUtils.*;
import static org.axonframework.eventsourcing.eventstore.EventUtils.asDomainEventMessage;
import static org.axonframework.serialization.MessageSerializer.serializeMetaData;
import static org.axonframework.serialization.MessageSerializer.serializePayload;
/**
* EventStorageEngine implementation that uses JDBC to store and fetch events.
* <p>
* By default the payload of events is stored as a serialized blob of bytes. Other columns are used to store meta-data
* that allow quick finding of DomainEvents for a specific aggregate in the correct order.
*
* @author Rene de Waele
*/
public class JdbcEventStorageEngine extends BatchingEventStorageEngine {
private static final long DEFAULT_LOWEST_GLOBAL_SEQUENCE = 1;
private static final int DEFAULT_MAX_GAP_OFFSET = 10000;
private final ConnectionProvider connectionProvider;
private final TransactionManager transactionManager;
private final Class<?> dataType;
private final EventSchema schema;
private final int maxGapOffset;
private final long lowestGlobalSequence;
/**
* Initializes an EventStorageEngine that uses JDBC to store and load events using the default {@link EventSchema}.
* The payload and metadata of events is stored as a serialized blob of bytes using a new {@link XStreamSerializer}.
* <p>
* Events are read in batches of 100. No upcasting is performed after the events have been fetched.
*
* @param connectionProvider The provider of connections to the underlying database
* @param transactionManager The instance managing transactions around fetching event data. Required by certain
* databases for reading blob data.
*/
public JdbcEventStorageEngine(ConnectionProvider connectionProvider, TransactionManager transactionManager) {
this(null, null, null, null, connectionProvider, transactionManager, byte[].class, new EventSchema(), null, null);
}
/**
* Initializes an EventStorageEngine that uses JDBC to store and load events using the default {@link EventSchema}.
* The payload and metadata of events is stored as a serialized blob of bytes using the given {@code serializer}.
* <p>
* Events are read in batches of 100. The given {@code upcasterChain} is used to upcast events before
* deserialization.
*
* @param serializer Used to serialize and deserialize event payload and metadata.
* @param upcasterChain Allows older revisions of serialized objects to be deserialized.
* @param persistenceExceptionResolver Detects concurrency exceptions from the backing database. If {@code null}
* persistence exceptions are not explicitly resolved.
* @param connectionProvider The provider of connections to the underlying database
* @param transactionManager The instance managing transactions around fetching event data. Required by certain
* databases for reading blob data.
*/
public JdbcEventStorageEngine(Serializer serializer, EventUpcaster upcasterChain,
PersistenceExceptionResolver persistenceExceptionResolver,
ConnectionProvider connectionProvider, TransactionManager transactionManager) {
this(serializer, upcasterChain, persistenceExceptionResolver, null, connectionProvider, transactionManager,
byte[].class, new EventSchema(), null, null);
}
/**
* Initializes an EventStorageEngine that uses JDBC to store and load events.
*
* @param serializer Used to serialize and deserialize event payload and metadata.
* @param upcasterChain Allows older revisions of serialized objects to be deserialized.
* @param persistenceExceptionResolver Detects concurrency exceptions from the backing database.
* @param batchSize The number of events that should be read at each database access. When more
* than this number of events must be read to rebuild an aggregate's state, the
* events are read in batches of this size. Tip: if you use a snapshotter, make
* sure to choose snapshot trigger and batch size such that a single batch will
* generally retrieve all events required to rebuild an aggregate's state.
* @param connectionProvider The provider of connections to the underlying database
* @param transactionManager The instance managing transactions around fetching event data. Required by certain
* databases for reading blob data.
* @param dataType The data type for serialized event payload and metadata
* @param schema Object that describes the database schema of event entries
* @param maxGapOffset The maximum distance in sequence numbers between a missing event and the
* event with the highest known index. If the gap is bigger it is assumed that
* the missing event will not be committed to the store anymore. This event
* storage engine will no longer look for those events the next time a batch is
* fetched.
* @param lowestGlobalSequence The first expected auto generated sequence number. For most data stores this
* is 1 unless the table has contained entries before.
*/
public JdbcEventStorageEngine(Serializer serializer, EventUpcaster upcasterChain,
PersistenceExceptionResolver persistenceExceptionResolver, Integer batchSize,
ConnectionProvider connectionProvider, TransactionManager transactionManager,
Class<?> dataType, EventSchema schema,
Integer maxGapOffset, Long lowestGlobalSequence) {
super(serializer, upcasterChain, getOrDefault(persistenceExceptionResolver, new JdbcSQLErrorCodesResolver()),
batchSize);
this.connectionProvider = connectionProvider;
this.transactionManager = transactionManager;
this.dataType = dataType;
this.schema = schema;
this.lowestGlobalSequence = getOrDefault(lowestGlobalSequence, DEFAULT_LOWEST_GLOBAL_SEQUENCE);
this.maxGapOffset = getOrDefault(maxGapOffset, DEFAULT_MAX_GAP_OFFSET);
}
/**
* Performs the DDL queries to create the schema necessary for this storage engine implementation.
*
* @param schemaFactory factory of the event schema
* @throws EventStoreException when an error occurs executing SQL statements
*/
public void createSchema(EventTableFactory schemaFactory) {
executeUpdates(getConnection(), e -> {
throw new EventStoreException("Failed to create event tables", e);
}, connection -> schemaFactory.createDomainEventTable(connection, schema),
connection -> schemaFactory.createSnapshotEventTable(connection, schema));
}
@Override
protected void appendEvents(List<? extends EventMessage<?>> events, Serializer serializer) {
if (events.isEmpty()) {
return;
}
final String table = schema.domainEventTable();
final String sql = "INSERT INTO " + table + " (" +
String.join(", ", schema.eventIdentifierColumn(), schema.aggregateIdentifierColumn(),
schema.sequenceNumberColumn(), schema.typeColumn(), schema.timestampColumn(),
schema.payloadTypeColumn(), schema.payloadRevisionColumn(), schema.payloadColumn(),
schema.metaDataColumn()) + ") VALUES (?,?,?,?,?,?,?,?,?)";
transactionManager.executeInTransaction(
() ->
executeBatch(getConnection(), connection -> {
PreparedStatement preparedStatement = connection.prepareStatement(sql);
for (EventMessage<?> eventMessage : events) {
DomainEventMessage<?> event = asDomainEventMessage(eventMessage);
SerializedObject<?> payload = serializePayload(event, serializer, dataType);
SerializedObject<?> metaData = serializeMetaData(event, serializer, dataType);
preparedStatement.setString(1, event.getIdentifier());
preparedStatement.setString(2, event.getAggregateIdentifier());
preparedStatement.setLong(3, event.getSequenceNumber());
preparedStatement.setString(4, event.getType());
writeTimestamp(preparedStatement, 5, event.getTimestamp());
preparedStatement.setString(6, payload.getType().getName());
preparedStatement.setString(7, payload.getType().getRevision());
preparedStatement.setObject(8, payload.getData());
preparedStatement.setObject(9, metaData.getData());
preparedStatement.addBatch();
}
return preparedStatement;
}, e -> handlePersistenceException(e, events.get(0))));
}
@Override
protected void storeSnapshot(DomainEventMessage<?> snapshot, Serializer serializer) {
transactionManager.executeInTransaction(
() -> executeUpdates(getConnection(), e -> handlePersistenceException(e, snapshot),
connection -> deleteSnapshots(connection, snapshot.getAggregateIdentifier()),
connection -> appendSnapshot(connection, snapshot, serializer)));
}
/**
* Creates a statement to append the given {@code snapshot} to the event storage using given {@code connection} to
* the database. Use the given {@code serializer} to serialize the payload and metadata of the event.
*
* @param connection The connection to the database
* @param snapshot The snapshot to append
* @param serializer The serializer that should be used when serializing the event's payload and metadata
* @return A {@link PreparedStatement} that appends the snapshot when executed
* @throws SQLException when an exception occurs while creating the prepared statement
*/
protected PreparedStatement appendSnapshot(Connection connection, DomainEventMessage<?> snapshot,
Serializer serializer) throws SQLException {
SerializedObject<?> payload = serializePayload(snapshot, serializer, dataType);
SerializedObject<?> metaData = serializeMetaData(snapshot, serializer, dataType);
final String sql = "INSERT INTO " + schema.snapshotTable() + " (" +
String.join(", ", schema.eventIdentifierColumn(), schema.aggregateIdentifierColumn(),
schema.sequenceNumberColumn(), schema.typeColumn(), schema.timestampColumn(),
schema.payloadTypeColumn(), schema.payloadRevisionColumn(), schema.payloadColumn(),
schema.metaDataColumn()) + ") VALUES (?,?,?,?,?,?,?,?,?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql); // NOSONAR
preparedStatement.setString(1, snapshot.getIdentifier());
preparedStatement.setString(2, snapshot.getAggregateIdentifier());
preparedStatement.setLong(3, snapshot.getSequenceNumber());
preparedStatement.setString(4, snapshot.getType());
writeTimestamp(preparedStatement, 5, snapshot.getTimestamp());
preparedStatement.setString(6, payload.getType().getName());
preparedStatement.setString(7, payload.getType().getRevision());
preparedStatement.setObject(8, payload.getData());
preparedStatement.setObject(9, metaData.getData());
return preparedStatement;
}
/**
* Creates a statement to delete all snapshots of the aggregate with given {@code aggregateIdentifier}.
*
* @param connection The connection to the database
* @param aggregateIdentifier The identifier of the aggregate whose snapshots to delete
* @return A {@link PreparedStatement} that deletes all the aggregate's snapshots when executed
* @throws SQLException when an exception occurs while creating the prepared statement
*/
protected PreparedStatement deleteSnapshots(Connection connection, String aggregateIdentifier) throws SQLException {
PreparedStatement preparedStatement = connection.prepareStatement(
"DELETE FROM " + schema.snapshotTable() + " WHERE " + schema.aggregateIdentifierColumn() + " = ?");
preparedStatement.setString(1, aggregateIdentifier);
return preparedStatement;
}
@Override
protected List<? extends DomainEventData<?>> fetchDomainEvents(String aggregateIdentifier, long firstSequenceNumber,
int batchSize) {
Transaction tx = transactionManager.startTransaction();
try {
return executeQuery(getConnection(),
connection -> readEventData(connection, aggregateIdentifier, firstSequenceNumber, batchSize),
listResults(this::getDomainEventData), e -> new EventStoreException(
format("Failed to read events for aggregate [%s]", aggregateIdentifier), e));
} finally {
tx.commit();
}
}
@Override
protected List<? extends TrackedEventData<?>> fetchTrackedEvents(TrackingToken lastToken, int batchSize) {
Transaction tx = transactionManager.startTransaction();
try {
return executeQuery(getConnection(),
connection -> readEventData(connection, lastToken, batchSize),
resultSet -> {
TrackingToken previousToken = lastToken;
List<TrackedEventData<?>> results = new ArrayList<>();
while (resultSet.next()) {
TrackedEventData<?> next = getTrackedEventData(resultSet, previousToken);
results.add(next);
previousToken = next.trackingToken();
}
return results;
}, e -> new EventStoreException(format("Failed to read events from token [%s]", lastToken), e));
} finally {
tx.commit();
}
}
@Override
protected Optional<? extends DomainEventData<?>> readSnapshotData(String aggregateIdentifier) {
Transaction tx = transactionManager.startTransaction();
try {
List<DomainEventData<?>> result =
executeQuery(getConnection(), connection -> readSnapshotData(connection, aggregateIdentifier),
listResults(this::getSnapshotData), e -> new EventStoreException(
format("Error reading aggregate snapshot [%s]", aggregateIdentifier), e));
return result.stream().findFirst();
} finally {
tx.commit();
}
}
/**
* Creates a statement to read domain event entries for an aggregate with given identifier starting with the first
* entry having a sequence number that is equal or larger than the given {@code firstSequenceNumber}.
*
* @param connection The connection to the database
* @param identifier The identifier of the aggregate
* @param firstSequenceNumber The expected sequence number of the first returned entry
* @return A {@link PreparedStatement} that returns event entries for the given query when executed
* @throws SQLException when an exception occurs while creating the prepared statement
*/
protected PreparedStatement readEventData(Connection connection, String identifier,
long firstSequenceNumber, int batchSize) throws SQLException {
Transaction tx = transactionManager.startTransaction();
try {
final String sql = "SELECT " + trackedEventFields() + " FROM " + schema.domainEventTable() + " WHERE " +
schema.aggregateIdentifierColumn() + " = ? AND " + schema.sequenceNumberColumn() + " >= ? AND " +
schema.sequenceNumberColumn() + " < ? ORDER BY " + schema.sequenceNumberColumn() + " ASC";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, identifier);
preparedStatement.setLong(2, firstSequenceNumber);
preparedStatement.setLong(3, firstSequenceNumber + batchSize);
return preparedStatement;
} finally {
tx.commit();
}
}
/**
* Creates a statement to read tracked event entries stored since given tracking token. Pass a {@code trackingToken}
* of {@code null} to create a statement for all entries in the storage.
*
* @param connection The connection to the database
* @param lastToken Object describing the global index of the last processed event or {@code null} to return all
* entries in the store
* @return A {@link PreparedStatement} that returns event entries for the given query when executed
* @throws SQLException when an exception occurs while creating the prepared statement
*/
protected PreparedStatement readEventData(Connection connection, TrackingToken lastToken,
int batchSize) throws SQLException {
Assert.isTrue(lastToken == null || lastToken instanceof GapAwareTrackingToken,
() -> format("Token [%s] is of the wrong type", lastToken));
GapAwareTrackingToken previousToken = (GapAwareTrackingToken) lastToken;
String sql = "SELECT " + trackedEventFields() + " FROM " + schema.domainEventTable() +
" WHERE (" + schema.globalIndexColumn() + " > ? AND " + schema.globalIndexColumn() + " <= ?) ";
List<Long> gaps;
if (previousToken != null) {
gaps = previousToken.getGaps().stream().collect(Collectors.toList());
if (!gaps.isEmpty()) {
sql += " OR " + schema.globalIndexColumn() + " IN (" +
String.join(",", Collections.nCopies(gaps.size(), "?")) + ") ";
}
} else {
gaps = Collections.emptyList();
}
sql += "ORDER BY " + schema.globalIndexColumn() + " ASC";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
long globalIndex = previousToken == null ? -1 : previousToken.getIndex();
preparedStatement.setLong(1, globalIndex);
preparedStatement.setLong(2, globalIndex + batchSize);
for (int i = 0; i < gaps.size(); i++) {
preparedStatement.setLong(i + 3, gaps.get(i));
}
return preparedStatement;
}
/**
* Creates a statement to read the snapshot entry of an aggregate with given identifier
*
* @param connection The connection to the database
* @param identifier The aggregate identifier
* @return A {@link PreparedStatement} that returns the last snapshot entry of the aggregate (if any) when executed
* @throws SQLException when an exception occurs while creating the prepared statement
*/
protected PreparedStatement readSnapshotData(Connection connection, String identifier) throws SQLException {
final String s = "SELECT " + domainEventFields() + " FROM " + schema.snapshotTable() + " WHERE " +
schema.aggregateIdentifierColumn() + " = ? ORDER BY " + schema.sequenceNumberColumn() + " DESC";
PreparedStatement statement = connection.prepareStatement(s);
statement.setString(1, identifier);
return statement;
}
/**
* Extracts the next tracked event entry from the given {@code resultSet}.
*
* @param resultSet The results of a query for tracked events
* @param previousToken The last known token of the tracker before obtaining this result set
* @return The next tracked event
* @throws SQLException when an exception occurs while creating the event data
*/
protected TrackedEventData<?> getTrackedEventData(ResultSet resultSet,
TrackingToken previousToken) throws SQLException {
long globalSequence = resultSet.getLong(schema.globalIndexColumn());
TrackingToken trackingToken;
if (previousToken == null) {
trackingToken = GapAwareTrackingToken.newInstance(globalSequence, LongStream
.range(Math.min(lowestGlobalSequence, globalSequence), globalSequence).mapToObj(Long::valueOf)
.collect(toSet()));
} else {
trackingToken = ((GapAwareTrackingToken) previousToken).advanceTo(globalSequence, maxGapOffset);
}
return new GenericTrackedDomainEventEntry<>(trackingToken, resultSet.getString(schema.typeColumn()),
resultSet.getString(schema.aggregateIdentifierColumn()),
resultSet.getLong(schema.sequenceNumberColumn()),
resultSet.getString(schema.eventIdentifierColumn()),
readTimeStamp(resultSet, schema.timestampColumn()),
resultSet.getString(schema.payloadTypeColumn()),
resultSet.getString(schema.payloadRevisionColumn()),
readPayload(resultSet, schema.payloadColumn()),
readPayload(resultSet, schema.metaDataColumn()));
}
/**
* Extracts the next domain event entry from the given {@code resultSet}.
*
* @param resultSet The results of a query for domain events of an aggregate
* @return The next domain event
* @throws SQLException when an exception occurs while creating the event data
*/
protected DomainEventData<?> getDomainEventData(ResultSet resultSet) throws SQLException {
return new GenericDomainEventEntry<>(resultSet.getString(schema.typeColumn()),
resultSet.getString(schema.aggregateIdentifierColumn()),
resultSet.getLong(schema.sequenceNumberColumn()),
resultSet.getString(schema.eventIdentifierColumn()),
readTimeStamp(resultSet, schema.timestampColumn()),
resultSet.getString(schema.payloadTypeColumn()),
resultSet.getString(schema.payloadRevisionColumn()),
readPayload(resultSet, schema.payloadColumn()),
readPayload(resultSet, schema.metaDataColumn()));
}
/**
* Extracts the next snapshot entry from the given {@code resultSet}.
*
* @param resultSet The results of a query for a snapshot of an aggregate
* @return The next snapshot data
* @throws SQLException when an exception occurs while creating the event data
*/
protected DomainEventData<?> getSnapshotData(ResultSet resultSet) throws SQLException {
return new GenericDomainEventEntry<>(resultSet.getString(schema.typeColumn()),
resultSet.getString(schema.aggregateIdentifierColumn()),
resultSet.getLong(schema.sequenceNumberColumn()),
resultSet.getString(schema.eventIdentifierColumn()),
readTimeStamp(resultSet, schema.timestampColumn()),
resultSet.getString(schema.payloadTypeColumn()),
resultSet.getString(schema.payloadRevisionColumn()),
readPayload(resultSet, schema.payloadColumn()),
readPayload(resultSet, schema.metaDataColumn()));
}
/**
* Reads a timestamp from the given {@code resultSet} at given {@code columnIndex}. The resultSet is
* positioned in the row that contains the data. This method must not change the row in the result set.
*
* @param resultSet The resultSet containing the stored data
* @param columnName The name of the column containing the timestamp
* @return an object describing the timestamp
* @throws SQLException when an exception occurs reading from the resultSet.
*/
protected Object readTimeStamp(ResultSet resultSet, String columnName) throws SQLException {
return resultSet.getString(columnName);
}
/**
* Write a timestamp from a {@link Instant} to a data value suitable for the database scheme.
*
* @param preparedStatement the statement to update
* @param position the position of the timestamp parameter in the statement
* @param timestamp {@link Instant} to convert
* @throws SQLException if modification of the statement fails
*/
protected void writeTimestamp(PreparedStatement preparedStatement, int position,
Instant timestamp) throws SQLException {
preparedStatement.setString(position, timestamp.toString());
}
/**
* Reads a serialized object from the given {@code resultSet} at given {@code columnIndex}. The resultSet
* is positioned in the row that contains the data. This method must not change the row in the result set.
*
* @param resultSet The resultSet containing the stored data
* @param columnName The name of the column containing the payload
* @return an object describing the serialized data
* @throws SQLException when an exception occurs reading from the resultSet.
*/
@SuppressWarnings("unchecked")
protected <T> T readPayload(ResultSet resultSet, String columnName) throws SQLException {
if (byte[].class.equals(dataType)) {
return (T) resultSet.getBytes(columnName);
}
return (T) resultSet.getObject(columnName);
}
/**
* Returns a comma separated list of domain event column names to select from an event or snapshot entry.
*
* @return comma separated domain event column names
*/
protected String domainEventFields() {
return String.join(", ", schema.eventIdentifierColumn(), schema.timestampColumn(), schema.payloadTypeColumn(),
schema.payloadRevisionColumn(), schema.payloadColumn(), schema.metaDataColumn(),
schema.typeColumn(), schema.aggregateIdentifierColumn(), schema.sequenceNumberColumn());
}
/**
* Returns a comma separated list of tracked domain event column names to select from an event entry.
*
* @return comma separated tracked domain event column names
*/
protected String trackedEventFields() {
return schema.globalIndexColumn() + ", " + domainEventFields();
}
/**
* Returns the {@link EventSchema} that defines the table and column names of event tables in the database.
*
* @return the event schema
*/
protected EventSchema schema() {
return schema;
}
/**
* Returns a {@link Connection} to the database.
*
* @return a database Connection
*/
protected Connection getConnection() {
try {
return connectionProvider.getConnection();
} catch (SQLException e) {
throw new EventStoreException("Failed to obtain a database connection", e);
}
}
}