Rename sbt akka modules
Co-authored-by: Sean Glover <sean@seanglover.com>
This commit is contained in:
parent
b92b749946
commit
24c03cde19
2930 changed files with 1466 additions and 1462 deletions
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package jdocs.org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.persistence.typed.ReplicaId;
|
||||
import org.apache.pekko.persistence.typed.ReplicationId;
|
||||
import org.apache.pekko.persistence.typed.javadsl.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
// #factory
|
||||
public class MyReplicatedBehavior
|
||||
extends ReplicatedEventSourcedBehavior<
|
||||
MyReplicatedBehavior.Command, MyReplicatedBehavior.Event, MyReplicatedBehavior.State> {
|
||||
// #factory
|
||||
interface Command {}
|
||||
|
||||
interface State {}
|
||||
|
||||
interface Event {}
|
||||
|
||||
// #replicas
|
||||
public static final ReplicaId DCA = new ReplicaId("DCA");
|
||||
public static final ReplicaId DCB = new ReplicaId("DCB");
|
||||
|
||||
public static final Set<ReplicaId> ALL_REPLICAS =
|
||||
Collections.unmodifiableSet(new HashSet<>(Arrays.asList(DCA, DCB)));
|
||||
// #replicas
|
||||
|
||||
// #factory-shared
|
||||
public static Behavior<Command> create(
|
||||
String entityId, ReplicaId replicaId, String queryPluginId) {
|
||||
return ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("MyReplicatedEntity", entityId, replicaId),
|
||||
ALL_REPLICAS,
|
||||
queryPluginId,
|
||||
MyReplicatedBehavior::new);
|
||||
}
|
||||
// #factory-shared
|
||||
|
||||
// #factory
|
||||
public static Behavior<Command> create(String entityId, ReplicaId replicaId) {
|
||||
Map<ReplicaId, String> allReplicasAndQueryPlugins = new HashMap<>();
|
||||
allReplicasAndQueryPlugins.put(DCA, "journalForDCA");
|
||||
allReplicasAndQueryPlugins.put(DCB, "journalForDCB");
|
||||
|
||||
return ReplicatedEventSourcing.perReplicaJournalConfig(
|
||||
new ReplicationId("MyReplicatedEntity", entityId, replicaId),
|
||||
allReplicasAndQueryPlugins,
|
||||
MyReplicatedBehavior::new);
|
||||
}
|
||||
|
||||
private MyReplicatedBehavior(ReplicationContext replicationContext) {
|
||||
super(replicationContext);
|
||||
}
|
||||
// #factory
|
||||
|
||||
@Override
|
||||
public State emptyState() {
|
||||
throw new UnsupportedOperationException("dummy for example");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<Command, Event, State> commandHandler() {
|
||||
throw new UnsupportedOperationException("dummy for example");
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<State, Event> eventHandler() {
|
||||
throw new UnsupportedOperationException("dummy for example");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package jdocs.org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.LogCapturing;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestKitJunitResource;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestProbe;
|
||||
import org.apache.pekko.actor.typed.ActorRef;
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.actor.typed.javadsl.ActorContext;
|
||||
import org.apache.pekko.actor.typed.javadsl.Behaviors;
|
||||
import org.apache.pekko.actor.typed.javadsl.TimerScheduler;
|
||||
import org.apache.pekko.persistence.testkit.PersistenceTestKitPlugin;
|
||||
import org.apache.pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.RecoveryCompleted;
|
||||
import org.apache.pekko.persistence.typed.ReplicaId;
|
||||
import org.apache.pekko.persistence.typed.ReplicationId;
|
||||
import org.apache.pekko.persistence.typed.javadsl.CommandHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.CommandHandlerBuilder;
|
||||
import org.apache.pekko.persistence.typed.javadsl.EventHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcedBehavior;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcing;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicationContext;
|
||||
import org.apache.pekko.persistence.typed.javadsl.SignalHandler;
|
||||
import org.apache.pekko.serialization.jackson.CborSerializable;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.scalatestplus.junit.JUnitSuite;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static jdocs.org.apache.pekko.persistence.typed.AuctionEntity.*;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ReplicatedAuctionExampleTest extends JUnitSuite {
|
||||
@ClassRule
|
||||
public static final TestKitJunitResource testKit =
|
||||
new TestKitJunitResource(PersistenceTestKitPlugin.getInstance().config());
|
||||
|
||||
@Rule public final LogCapturing logCapturing = new LogCapturing();
|
||||
|
||||
@Test
|
||||
public void auctionExample() {
|
||||
String auctionName = "old-skis";
|
||||
Bid initialBid = new Bid("chbatey", 12, Instant.now(), R1);
|
||||
Instant closeAt = Instant.now().plusSeconds(10);
|
||||
|
||||
ActorRef<Command> replicaA =
|
||||
testKit.spawn(AuctionEntity.create(R1, auctionName, initialBid, closeAt, true));
|
||||
ActorRef<Command> replicaB =
|
||||
testKit.spawn(AuctionEntity.create(R2, auctionName, initialBid, closeAt, false));
|
||||
|
||||
replicaA.tell(new OfferBid("me", 100));
|
||||
replicaA.tell(new OfferBid("me", 99));
|
||||
replicaA.tell(new OfferBid("me", 202));
|
||||
|
||||
TestProbe<Bid> replyProbe = testKit.createTestProbe();
|
||||
replyProbe.awaitAssert(
|
||||
() -> {
|
||||
replicaA.tell(new GetHighestBid(replyProbe.ref()));
|
||||
Bid bid = replyProbe.expectMessageClass(Bid.class);
|
||||
assertEquals(202, bid.offer);
|
||||
return bid;
|
||||
});
|
||||
|
||||
replicaA.tell(Finish.INSTANCE);
|
||||
|
||||
TestProbe<Boolean> finishProbe = testKit.createTestProbe();
|
||||
finishProbe.awaitAssert(
|
||||
() -> {
|
||||
replicaA.tell(new IsClosed(finishProbe.ref()));
|
||||
return finishProbe.expectMessage(true);
|
||||
});
|
||||
finishProbe.awaitAssert(
|
||||
() -> {
|
||||
replicaB.tell(new IsClosed(finishProbe.ref()));
|
||||
return finishProbe.expectMessage(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #setup
|
||||
class AuctionEntity
|
||||
extends ReplicatedEventSourcedBehavior<
|
||||
AuctionEntity.Command, AuctionEntity.Event, AuctionEntity.AuctionState> {
|
||||
|
||||
public static ReplicaId R1 = new ReplicaId("R1");
|
||||
public static ReplicaId R2 = new ReplicaId("R2");
|
||||
|
||||
public static Set<ReplicaId> ALL_REPLICAS = new HashSet<>(Arrays.asList(R1, R2));
|
||||
|
||||
private final ActorContext<Command> context;
|
||||
private final TimerScheduler<Command> timers;
|
||||
private final Bid initialBid;
|
||||
private final Instant closingAt;
|
||||
private final boolean responsibleForClosing;
|
||||
|
||||
// #setup
|
||||
|
||||
// #commands
|
||||
public static final class Bid {
|
||||
public final String bidder;
|
||||
public final int offer;
|
||||
public final Instant timestamp;
|
||||
public final ReplicaId originReplica;
|
||||
|
||||
public Bid(String bidder, int offer, Instant timestamp, ReplicaId originReplica) {
|
||||
this.bidder = bidder;
|
||||
this.offer = offer;
|
||||
this.timestamp = timestamp;
|
||||
this.originReplica = originReplica;
|
||||
}
|
||||
}
|
||||
|
||||
interface Command extends CborSerializable {}
|
||||
|
||||
public enum Finish implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public static final class OfferBid implements Command {
|
||||
public final String bidder;
|
||||
public final int offer;
|
||||
|
||||
public OfferBid(String bidder, int offer) {
|
||||
this.bidder = bidder;
|
||||
this.offer = offer;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GetHighestBid implements Command {
|
||||
public final ActorRef<Bid> replyTo;
|
||||
|
||||
public GetHighestBid(ActorRef<Bid> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class IsClosed implements Command {
|
||||
public final ActorRef<Boolean> replyTo;
|
||||
|
||||
public IsClosed(ActorRef<Boolean> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Close implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
// #commands
|
||||
|
||||
// #events
|
||||
interface Event extends CborSerializable {}
|
||||
|
||||
public static final class BidRegistered implements Event {
|
||||
public final Bid bid;
|
||||
|
||||
@JsonCreator
|
||||
public BidRegistered(Bid bid) {
|
||||
this.bid = bid;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class AuctionFinished implements Event {
|
||||
public final ReplicaId atReplica;
|
||||
|
||||
@JsonCreator
|
||||
public AuctionFinished(ReplicaId atReplica) {
|
||||
this.atReplica = atReplica;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class WinnerDecided implements Event {
|
||||
public final ReplicaId atReplica;
|
||||
public final Bid winningBid;
|
||||
public final int amount;
|
||||
|
||||
public WinnerDecided(ReplicaId atReplica, Bid winningBid, int amount) {
|
||||
this.atReplica = atReplica;
|
||||
this.winningBid = winningBid;
|
||||
this.amount = amount;
|
||||
}
|
||||
}
|
||||
// #events
|
||||
|
||||
// #state
|
||||
static class AuctionState implements CborSerializable {
|
||||
|
||||
final boolean stillRunning;
|
||||
final Bid highestBid;
|
||||
final int highestCounterOffer;
|
||||
final Set<String> finishedAtDc;
|
||||
|
||||
AuctionState(
|
||||
boolean stillRunning, Bid highestBid, int highestCounterOffer, Set<String> finishedAtDc) {
|
||||
this.stillRunning = stillRunning;
|
||||
this.highestBid = highestBid;
|
||||
this.highestCounterOffer = highestCounterOffer;
|
||||
this.finishedAtDc = finishedAtDc;
|
||||
}
|
||||
|
||||
AuctionState withNewHighestBid(Bid bid) {
|
||||
assertTrue(stillRunning);
|
||||
assertTrue(isHigherBid(bid, highestBid));
|
||||
return new AuctionState(
|
||||
stillRunning, bid, highestBid.offer, finishedAtDc); // keep last highest bid around
|
||||
}
|
||||
|
||||
AuctionState withTooLowBid(Bid bid) {
|
||||
assertTrue(stillRunning);
|
||||
assertTrue(isHigherBid(highestBid, bid));
|
||||
return new AuctionState(
|
||||
stillRunning, highestBid, Math.max(highestCounterOffer, bid.offer), finishedAtDc);
|
||||
}
|
||||
|
||||
static Boolean isHigherBid(Bid first, Bid second) {
|
||||
return first.offer > second.offer
|
||||
|| (first.offer == second.offer && first.timestamp.isBefore(second.timestamp))
|
||||
|| // if equal, first one wins
|
||||
// If timestamps are equal, choose by dc where the offer was submitted
|
||||
// In real auctions, this last comparison should be deterministic but unpredictable, so
|
||||
// that submitting to a
|
||||
// particular DC would not be an advantage.
|
||||
(first.offer == second.offer
|
||||
&& first.timestamp.equals(second.timestamp)
|
||||
&& first.originReplica.id().compareTo(second.originReplica.id()) < 0);
|
||||
}
|
||||
|
||||
AuctionState addFinishedAtReplica(String replica) {
|
||||
Set<String> s = new HashSet<>(finishedAtDc);
|
||||
s.add(replica);
|
||||
return new AuctionState(
|
||||
false, highestBid, highestCounterOffer, Collections.unmodifiableSet(s));
|
||||
}
|
||||
|
||||
public AuctionState close() {
|
||||
return new AuctionState(false, highestBid, highestCounterOffer, Collections.emptySet());
|
||||
}
|
||||
|
||||
public boolean isClosed() {
|
||||
return !stillRunning && finishedAtDc.isEmpty();
|
||||
}
|
||||
}
|
||||
// #state
|
||||
|
||||
// #setup
|
||||
public static Behavior<Command> create(
|
||||
ReplicaId replica,
|
||||
String name,
|
||||
Bid initialBid,
|
||||
Instant closingAt,
|
||||
boolean responsibleForClosing) {
|
||||
return Behaviors.setup(
|
||||
ctx ->
|
||||
Behaviors.withTimers(
|
||||
timers ->
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("Auction", name, replica),
|
||||
ALL_REPLICAS,
|
||||
PersistenceTestKitReadJournal.Identifier(),
|
||||
replicationCtx ->
|
||||
new AuctionEntity(
|
||||
ctx,
|
||||
replicationCtx,
|
||||
timers,
|
||||
initialBid,
|
||||
closingAt,
|
||||
responsibleForClosing))));
|
||||
}
|
||||
|
||||
private AuctionEntity(
|
||||
ActorContext<Command> context,
|
||||
ReplicationContext replicationContext,
|
||||
TimerScheduler<Command> timers,
|
||||
Bid initialBid,
|
||||
Instant closingAt,
|
||||
boolean responsibleForClosing) {
|
||||
super(replicationContext);
|
||||
this.context = context;
|
||||
this.timers = timers;
|
||||
this.initialBid = initialBid;
|
||||
this.closingAt = closingAt;
|
||||
this.responsibleForClosing = responsibleForClosing;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuctionState emptyState() {
|
||||
return new AuctionState(true, initialBid, initialBid.offer, Collections.emptySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignalHandler<AuctionState> signalHandler() {
|
||||
return newSignalHandlerBuilder()
|
||||
.onSignal(RecoveryCompleted.instance(), this::onRecoveryCompleted)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void onRecoveryCompleted(AuctionState state) {
|
||||
if (shouldClose(state)) {
|
||||
context.getSelf().tell(Close.INSTANCE);
|
||||
}
|
||||
|
||||
long millisUntilClosing =
|
||||
closingAt.toEpochMilli() - getReplicationContext().currentTimeMillis();
|
||||
timers.startSingleTimer(Finish.INSTANCE, Duration.ofMillis(millisUntilClosing));
|
||||
}
|
||||
|
||||
// #setup
|
||||
|
||||
// #command-handler
|
||||
@Override
|
||||
public CommandHandler<Command, Event, AuctionState> commandHandler() {
|
||||
|
||||
CommandHandlerBuilder<Command, Event, AuctionState> builder = newCommandHandlerBuilder();
|
||||
|
||||
// running
|
||||
builder
|
||||
.forState(state -> state.stillRunning)
|
||||
.onCommand(
|
||||
OfferBid.class,
|
||||
(state, bid) ->
|
||||
Effect()
|
||||
.persist(
|
||||
new BidRegistered(
|
||||
new Bid(
|
||||
bid.bidder,
|
||||
bid.offer,
|
||||
Instant.ofEpochMilli(
|
||||
this.getReplicationContext().currentTimeMillis()),
|
||||
this.getReplicationContext().replicaId()))))
|
||||
.onCommand(
|
||||
GetHighestBid.class,
|
||||
(state, get) -> {
|
||||
get.replyTo.tell(state.highestBid);
|
||||
return Effect().none();
|
||||
})
|
||||
.onCommand(
|
||||
Finish.class,
|
||||
(state, finish) ->
|
||||
Effect().persist(new AuctionFinished(getReplicationContext().replicaId())))
|
||||
.onCommand(Close.class, (state, close) -> Effect().unhandled())
|
||||
.onCommand(
|
||||
IsClosed.class,
|
||||
(state, get) -> {
|
||||
get.replyTo.tell(false);
|
||||
return Effect().none();
|
||||
});
|
||||
|
||||
// finished
|
||||
builder
|
||||
.forAnyState()
|
||||
.onCommand(OfferBid.class, (state, bid) -> Effect().unhandled())
|
||||
.onCommand(
|
||||
GetHighestBid.class,
|
||||
(state, get) -> {
|
||||
get.replyTo.tell(state.highestBid);
|
||||
return Effect().none();
|
||||
})
|
||||
.onCommand(
|
||||
Finish.class,
|
||||
(state, finish) ->
|
||||
Effect().persist(new AuctionFinished(getReplicationContext().replicaId())))
|
||||
.onCommand(
|
||||
Close.class,
|
||||
(state, close) ->
|
||||
Effect()
|
||||
.persist(
|
||||
new WinnerDecided(
|
||||
getReplicationContext().replicaId(),
|
||||
state.highestBid,
|
||||
state.highestCounterOffer)))
|
||||
.onCommand(
|
||||
IsClosed.class,
|
||||
(state, get) -> {
|
||||
get.replyTo.tell(state.isClosed());
|
||||
return Effect().none();
|
||||
});
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
// #command-handler
|
||||
|
||||
// #event-handler
|
||||
@Override
|
||||
public EventHandler<AuctionState, Event> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onEvent(
|
||||
BidRegistered.class,
|
||||
(state, event) -> {
|
||||
if (AuctionState.isHigherBid(event.bid, state.highestBid)) {
|
||||
return state.withNewHighestBid(event.bid);
|
||||
} else {
|
||||
return state.withTooLowBid(event.bid);
|
||||
}
|
||||
})
|
||||
.onEvent(
|
||||
AuctionFinished.class,
|
||||
(state, event) -> {
|
||||
AuctionState newState = state.addFinishedAtReplica(event.atReplica.id());
|
||||
if (state.isClosed()) return state; // already closed
|
||||
else if (!getReplicationContext().recoveryRunning()) {
|
||||
eventTriggers(event, newState);
|
||||
}
|
||||
return newState;
|
||||
})
|
||||
.onEvent(WinnerDecided.class, (state, event) -> state.close())
|
||||
.build();
|
||||
}
|
||||
// #event-handler
|
||||
|
||||
// #event-triggers
|
||||
private void eventTriggers(AuctionFinished event, AuctionState newState) {
|
||||
if (newState.finishedAtDc.contains(getReplicationContext().replicaId().id())) {
|
||||
if (shouldClose(newState)) {
|
||||
context.getSelf().tell(Close.INSTANCE);
|
||||
}
|
||||
} else {
|
||||
context.getSelf().tell(Finish.INSTANCE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldClose(AuctionState state) {
|
||||
return responsibleForClosing
|
||||
&& !state.isClosed()
|
||||
&& getReplicationContext().getAllReplicas().stream()
|
||||
.map(ReplicaId::id)
|
||||
.collect(Collectors.toSet())
|
||||
.equals(state.finishedAtDc);
|
||||
}
|
||||
// #event-triggers
|
||||
|
||||
// #setup
|
||||
}
|
||||
// #setup
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package jdocs.org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.Done;
|
||||
import org.apache.pekko.actor.typed.ActorRef;
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.actor.typed.javadsl.ActorContext;
|
||||
import org.apache.pekko.actor.typed.javadsl.Behaviors;
|
||||
import org.apache.pekko.persistence.testkit.query.javadsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.ReplicaId;
|
||||
import org.apache.pekko.persistence.typed.ReplicationId;
|
||||
import org.apache.pekko.persistence.typed.crdt.LwwTime;
|
||||
import org.apache.pekko.persistence.typed.javadsl.CommandHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.Effect;
|
||||
import org.apache.pekko.persistence.typed.javadsl.EventHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcedBehavior;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcing;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicationContext;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
interface ReplicatedBlogExample {
|
||||
|
||||
public final class BlogEntity
|
||||
extends ReplicatedEventSourcedBehavior<
|
||||
BlogEntity.Command, BlogEntity.Event, BlogEntity.BlogState> {
|
||||
|
||||
private final ActorContext<Command> context;
|
||||
|
||||
interface Command {
|
||||
String getPostId();
|
||||
}
|
||||
|
||||
static final class AddPost implements Command {
|
||||
final String postId;
|
||||
final PostContent content;
|
||||
final ActorRef<AddPostDone> replyTo;
|
||||
|
||||
public AddPost(String postId, PostContent content, ActorRef<AddPostDone> replyTo) {
|
||||
this.postId = postId;
|
||||
this.content = content;
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
|
||||
public String getPostId() {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
static final class AddPostDone {
|
||||
final String postId;
|
||||
|
||||
AddPostDone(String postId) {
|
||||
this.postId = postId;
|
||||
}
|
||||
|
||||
public String getPostId() {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
static final class GetPost implements Command {
|
||||
final String postId;
|
||||
final ActorRef<PostContent> replyTo;
|
||||
|
||||
public GetPost(String postId, ActorRef<PostContent> replyTo) {
|
||||
this.postId = postId;
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
|
||||
public String getPostId() {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
static final class ChangeBody implements Command {
|
||||
final String postId;
|
||||
final PostContent newContent;
|
||||
final ActorRef<Done> replyTo;
|
||||
|
||||
public ChangeBody(String postId, PostContent newContent, ActorRef<Done> replyTo) {
|
||||
this.postId = postId;
|
||||
this.newContent = newContent;
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
|
||||
public String getPostId() {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
static final class Publish implements Command {
|
||||
final String postId;
|
||||
final ActorRef<Done> replyTo;
|
||||
|
||||
public Publish(String postId, ActorRef<Done> replyTo) {
|
||||
this.postId = postId;
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
|
||||
public String getPostId() {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
interface Event {}
|
||||
|
||||
static final class PostAdded implements Event {
|
||||
final String postId;
|
||||
final PostContent content;
|
||||
final LwwTime timestamp;
|
||||
|
||||
public PostAdded(String postId, PostContent content, LwwTime timestamp) {
|
||||
this.postId = postId;
|
||||
this.content = content;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
static final class BodyChanged implements Event {
|
||||
final String postId;
|
||||
final PostContent content;
|
||||
final LwwTime timestamp;
|
||||
|
||||
public BodyChanged(String postId, PostContent content, LwwTime timestamp) {
|
||||
this.postId = postId;
|
||||
this.content = content;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
static final class Published implements Event {
|
||||
final String postId;
|
||||
|
||||
public Published(String postId) {
|
||||
this.postId = postId;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PostContent {
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
public PostContent(String title, String body) {
|
||||
this.title = title;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
public static class BlogState {
|
||||
|
||||
public static final BlogState EMPTY =
|
||||
new BlogState(Optional.empty(), new LwwTime(Long.MIN_VALUE, new ReplicaId("")), false);
|
||||
|
||||
final Optional<PostContent> content;
|
||||
final LwwTime contentTimestamp;
|
||||
final boolean published;
|
||||
|
||||
public BlogState(Optional<PostContent> content, LwwTime contentTimestamp, boolean published) {
|
||||
this.content = content;
|
||||
this.contentTimestamp = contentTimestamp;
|
||||
this.published = published;
|
||||
}
|
||||
|
||||
BlogState withContent(PostContent newContent, LwwTime timestamp) {
|
||||
return new BlogState(Optional.of(newContent), timestamp, this.published);
|
||||
}
|
||||
|
||||
BlogState publish() {
|
||||
if (published) {
|
||||
return this;
|
||||
} else {
|
||||
return new BlogState(content, contentTimestamp, true);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return !content.isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
public static Behavior<Command> create(
|
||||
String entityId, ReplicaId replicaId, Set<ReplicaId> allReplicas) {
|
||||
return Behaviors.setup(
|
||||
context ->
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("blog", entityId, replicaId),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier(),
|
||||
replicationContext -> new BlogEntity(context, replicationContext)));
|
||||
}
|
||||
|
||||
private BlogEntity(ActorContext<Command> context, ReplicationContext replicationContext) {
|
||||
super(replicationContext);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlogState emptyState() {
|
||||
return BlogState.EMPTY;
|
||||
}
|
||||
|
||||
// #command-handler
|
||||
@Override
|
||||
public CommandHandler<Command, Event, BlogState> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(AddPost.class, this::onAddPost)
|
||||
.onCommand(ChangeBody.class, this::onChangeBody)
|
||||
.onCommand(Publish.class, this::onPublish)
|
||||
.onCommand(GetPost.class, this::onGetPost)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Effect<Event, BlogState> onAddPost(BlogState state, AddPost command) {
|
||||
PostAdded evt =
|
||||
new PostAdded(
|
||||
getReplicationContext().entityId(),
|
||||
command.content,
|
||||
state.contentTimestamp.increase(
|
||||
getReplicationContext().currentTimeMillis(),
|
||||
getReplicationContext().replicaId()));
|
||||
return Effect()
|
||||
.persist(evt)
|
||||
.thenRun(() -> command.replyTo.tell(new AddPostDone(getReplicationContext().entityId())));
|
||||
}
|
||||
|
||||
private Effect<Event, BlogState> onChangeBody(BlogState state, ChangeBody command) {
|
||||
BodyChanged evt =
|
||||
new BodyChanged(
|
||||
getReplicationContext().entityId(),
|
||||
command.newContent,
|
||||
state.contentTimestamp.increase(
|
||||
getReplicationContext().currentTimeMillis(),
|
||||
getReplicationContext().replicaId()));
|
||||
return Effect().persist(evt).thenRun(() -> command.replyTo.tell(Done.getInstance()));
|
||||
}
|
||||
|
||||
private Effect<Event, BlogState> onPublish(BlogState state, Publish command) {
|
||||
Published evt = new Published(getReplicationContext().entityId());
|
||||
return Effect().persist(evt).thenRun(() -> command.replyTo.tell(Done.getInstance()));
|
||||
}
|
||||
|
||||
private Effect<Event, BlogState> onGetPost(BlogState state, GetPost command) {
|
||||
context.getLog().info("GetPost {}", state.content);
|
||||
if (state.content.isPresent()) command.replyTo.tell(state.content.get());
|
||||
return Effect().none();
|
||||
}
|
||||
// #command-handler
|
||||
|
||||
// #event-handler
|
||||
@Override
|
||||
public EventHandler<BlogState, Event> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onEvent(PostAdded.class, this::onPostAdded)
|
||||
.onEvent(BodyChanged.class, this::onBodyChanged)
|
||||
.onEvent(Published.class, this::onPublished)
|
||||
.build();
|
||||
}
|
||||
|
||||
private BlogState onPostAdded(BlogState state, PostAdded event) {
|
||||
if (event.timestamp.isAfter(state.contentTimestamp)) {
|
||||
BlogState s = state.withContent(event.content, event.timestamp);
|
||||
context.getLog().info("Updating content. New content is {}", s);
|
||||
return s;
|
||||
} else {
|
||||
context.getLog().info("Ignoring event as timestamp is older");
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
private BlogState onBodyChanged(BlogState state, BodyChanged event) {
|
||||
if (event.timestamp.isAfter(state.contentTimestamp)) {
|
||||
return state.withContent(event.content, event.timestamp);
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
private BlogState onPublished(BlogState state, Published event) {
|
||||
return state.publish();
|
||||
}
|
||||
// #event-handler
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package jdocs.org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.actor.typed.ActorRef;
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.persistence.testkit.query.javadsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.ReplicaId;
|
||||
import org.apache.pekko.persistence.typed.ReplicationId;
|
||||
import org.apache.pekko.persistence.typed.crdt.ORSet;
|
||||
import org.apache.pekko.persistence.typed.javadsl.CommandHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.EventHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcedBehavior;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcing;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicationContext;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
interface ReplicatedMovieExample {
|
||||
|
||||
// #movie-entity
|
||||
public final class MovieWatchList
|
||||
extends ReplicatedEventSourcedBehavior<MovieWatchList.Command, ORSet.DeltaOp, ORSet<String>> {
|
||||
|
||||
interface Command {}
|
||||
|
||||
public static class AddMovie implements Command {
|
||||
public final String movieId;
|
||||
|
||||
public AddMovie(String movieId) {
|
||||
this.movieId = movieId;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RemoveMovie implements Command {
|
||||
public final String movieId;
|
||||
|
||||
public RemoveMovie(String movieId) {
|
||||
this.movieId = movieId;
|
||||
}
|
||||
}
|
||||
|
||||
public static class GetMovieList implements Command {
|
||||
public final ActorRef<MovieList> replyTo;
|
||||
|
||||
public GetMovieList(ActorRef<MovieList> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MovieList {
|
||||
public final Set<String> movieIds;
|
||||
|
||||
public MovieList(Set<String> movieIds) {
|
||||
this.movieIds = Collections.unmodifiableSet(movieIds);
|
||||
}
|
||||
}
|
||||
|
||||
public static Behavior<Command> create(
|
||||
String entityId, ReplicaId replicaId, Set<ReplicaId> allReplicas) {
|
||||
return ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("movies", entityId, replicaId),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier(),
|
||||
MovieWatchList::new);
|
||||
}
|
||||
|
||||
private MovieWatchList(ReplicationContext replicationContext) {
|
||||
super(replicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ORSet<String> emptyState() {
|
||||
return ORSet.empty(getReplicationContext().replicaId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<Command, ORSet.DeltaOp, ORSet<String>> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(
|
||||
AddMovie.class, (state, command) -> Effect().persist(state.add(command.movieId)))
|
||||
.onCommand(
|
||||
RemoveMovie.class,
|
||||
(state, command) -> Effect().persist(state.remove(command.movieId)))
|
||||
.onCommand(
|
||||
GetMovieList.class,
|
||||
(state, command) -> {
|
||||
command.replyTo.tell(new MovieList(state.getElements()));
|
||||
return Effect().none();
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<ORSet<String>, ORSet.DeltaOp> eventHandler() {
|
||||
return newEventHandlerBuilder().forAnyState().onAnyEvent(ORSet::applyOperation);
|
||||
}
|
||||
}
|
||||
// #movie-entity
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package jdocs.org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.actor.typed.ActorRef;
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.persistence.testkit.query.javadsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.ReplicaId;
|
||||
import org.apache.pekko.persistence.typed.ReplicationId;
|
||||
import org.apache.pekko.persistence.typed.crdt.Counter;
|
||||
import org.apache.pekko.persistence.typed.javadsl.CommandHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.Effect;
|
||||
import org.apache.pekko.persistence.typed.javadsl.EventHandler;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcedBehavior;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicatedEventSourcing;
|
||||
import org.apache.pekko.persistence.typed.javadsl.ReplicationContext;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
interface ReplicatedShoppingCartExample {
|
||||
|
||||
// #shopping-cart
|
||||
public final class ShoppingCart
|
||||
extends ReplicatedEventSourcedBehavior<
|
||||
ShoppingCart.Command, ShoppingCart.Event, ShoppingCart.State> {
|
||||
|
||||
public interface Event {}
|
||||
|
||||
public static final class ItemUpdated implements Event {
|
||||
public final String productId;
|
||||
public final Counter.Updated update;
|
||||
|
||||
public ItemUpdated(String productId, Counter.Updated update) {
|
||||
this.productId = productId;
|
||||
this.update = update;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Command {}
|
||||
|
||||
public static final class AddItem implements Command {
|
||||
public final String productId;
|
||||
public final int count;
|
||||
|
||||
public AddItem(String productId, int count) {
|
||||
this.productId = productId;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RemoveItem implements Command {
|
||||
public final String productId;
|
||||
public final int count;
|
||||
|
||||
public RemoveItem(String productId, int count) {
|
||||
this.productId = productId;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
|
||||
public static class GetCartItems implements Command {
|
||||
public final ActorRef<CartItems> replyTo;
|
||||
|
||||
public GetCartItems(ActorRef<CartItems> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class CartItems {
|
||||
public final Map<String, Integer> items;
|
||||
|
||||
public CartItems(Map<String, Integer> items) {
|
||||
this.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class State {
|
||||
public final Map<String, Counter> items = new HashMap<>();
|
||||
}
|
||||
|
||||
public static Behavior<Command> create(
|
||||
String entityId, ReplicaId replicaId, Set<ReplicaId> allReplicas) {
|
||||
return ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("blog", entityId, replicaId),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier(),
|
||||
ShoppingCart::new);
|
||||
}
|
||||
|
||||
private ShoppingCart(ReplicationContext replicationContext) {
|
||||
super(replicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public State emptyState() {
|
||||
return new State();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<Command, Event, State> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(AddItem.class, this::onAddItem)
|
||||
.onCommand(RemoveItem.class, this::onRemoveItem)
|
||||
.onCommand(GetCartItems.class, this::onGetCartItems)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Effect<Event, State> onAddItem(State state, AddItem command) {
|
||||
return Effect()
|
||||
.persist(new ItemUpdated(command.productId, new Counter.Updated(command.count)));
|
||||
}
|
||||
|
||||
private Effect<Event, State> onRemoveItem(State state, RemoveItem command) {
|
||||
return Effect()
|
||||
.persist(new ItemUpdated(command.productId, new Counter.Updated(-command.count)));
|
||||
}
|
||||
|
||||
private Effect<Event, State> onGetCartItems(State state, GetCartItems command) {
|
||||
command.replyTo.tell(new CartItems(filterEmptyAndNegative(state.items)));
|
||||
return Effect().none();
|
||||
}
|
||||
|
||||
private Map<String, Integer> filterEmptyAndNegative(Map<String, Counter> cart) {
|
||||
Map<String, Integer> result = new HashMap<>();
|
||||
for (Map.Entry<String, Counter> entry : cart.entrySet()) {
|
||||
int count = entry.getValue().value().intValue();
|
||||
if (count > 0) result.put(entry.getKey(), count);
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<State, Event> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onEvent(ItemUpdated.class, this::onItemUpdated)
|
||||
.build();
|
||||
}
|
||||
|
||||
private State onItemUpdated(State state, ItemUpdated event) {
|
||||
final Counter counterForProduct;
|
||||
if (state.items.containsKey(event.productId)) {
|
||||
counterForProduct = state.items.get(event.productId);
|
||||
} else {
|
||||
counterForProduct = Counter.empty();
|
||||
}
|
||||
state.items.put(event.productId, counterForProduct.applyOperation(event.update));
|
||||
return state;
|
||||
}
|
||||
}
|
||||
// #shopping-cart
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package jdocs.org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.persistence.testkit.query.javadsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.ReplicaId;
|
||||
import org.apache.pekko.persistence.typed.ReplicationId;
|
||||
import org.apache.pekko.persistence.typed.javadsl.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ReplicatedStringSet
|
||||
extends ReplicatedEventSourcedBehavior<ReplicatedStringSet.Command, String, Set<String>> {
|
||||
interface Command {}
|
||||
|
||||
public static final class AddString implements Command {
|
||||
final String string;
|
||||
|
||||
public AddString(String string) {
|
||||
this.string = string;
|
||||
}
|
||||
}
|
||||
|
||||
public static Behavior<Command> create(
|
||||
String entityId, ReplicaId replicaId, Set<ReplicaId> allReplicas) {
|
||||
return ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("StringSet", entityId, replicaId),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier(),
|
||||
ReplicatedStringSet::new);
|
||||
}
|
||||
|
||||
private ReplicatedStringSet(ReplicationContext replicationContext) {
|
||||
super(replicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> emptyState() {
|
||||
return new HashSet<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<Command, String, Set<String>> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(
|
||||
AddString.class,
|
||||
(state, cmd) -> {
|
||||
if (!state.contains(cmd.string)) return Effect().persist(cmd.string);
|
||||
else return Effect().none();
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<Set<String>, String> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onAnyEvent(
|
||||
(set, string) -> {
|
||||
HashSet<String> newState = new HashSet<>(set);
|
||||
newState.add(string);
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
|
||||
// #tagging
|
||||
@Override
|
||||
public Set<String> tagsFor(String event) {
|
||||
// don't apply tags if event was replicated here, it already will appear in queries by tag
|
||||
// as the origin replica would have tagged it already
|
||||
if (getReplicationContext().replicaId() != getReplicationContext().origin()) {
|
||||
return new HashSet<>();
|
||||
} else {
|
||||
Set<String> tags = new HashSet<>();
|
||||
tags.add("strings");
|
||||
if (event.length() > 10) tags.add("long-strings");
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
// #tagging
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed;
|
||||
|
||||
import org.apache.pekko.Done;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.LogCapturing;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestKitJunitResource;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestProbe;
|
||||
import org.apache.pekko.actor.typed.ActorRef;
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.persistence.testkit.PersistenceTestKitPlugin;
|
||||
import org.apache.pekko.persistence.testkit.query.javadsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.javadsl.*;
|
||||
import com.typesafe.config.ConfigFactory;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.scalatestplus.junit.JUnitSuite;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.apache.pekko.Done.done;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class ReplicatedEventSourcingTest extends JUnitSuite {
|
||||
|
||||
static final class TestBehavior
|
||||
extends ReplicatedEventSourcedBehavior<TestBehavior.Command, String, Set<String>> {
|
||||
interface Command {}
|
||||
|
||||
static final class GetState implements Command {
|
||||
final ActorRef<State> replyTo;
|
||||
|
||||
public GetState(ActorRef<State> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
static final class StoreMe implements Command {
|
||||
final String text;
|
||||
final ActorRef<Done> replyTo;
|
||||
|
||||
public StoreMe(String text, ActorRef<Done> replyTo) {
|
||||
this.text = text;
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
static final class StoreUs implements Command {
|
||||
final List<String> texts;
|
||||
final ActorRef<Done> replyTo;
|
||||
|
||||
public StoreUs(List<String> texts, ActorRef<Done> replyTo) {
|
||||
this.texts = texts;
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
static final class GetReplica implements Command {
|
||||
final ActorRef<ReplicaId> replyTo;
|
||||
|
||||
public GetReplica(ActorRef<ReplicaId> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
static final class State {
|
||||
final Set<String> texts;
|
||||
|
||||
public State(Set<String> texts) {
|
||||
this.texts = texts;
|
||||
}
|
||||
}
|
||||
|
||||
enum Stop implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public static Behavior<Command> create(
|
||||
String entityId, ReplicaId replicaId, Set<ReplicaId> allReplicas) {
|
||||
return ReplicatedEventSourcing.commonJournalConfig(
|
||||
new ReplicationId("ReplicatedEventSourcingTest", entityId, replicaId),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier(),
|
||||
TestBehavior::new);
|
||||
}
|
||||
|
||||
private TestBehavior(ReplicationContext replicationContext) {
|
||||
super(replicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String journalPluginId() {
|
||||
return PersistenceTestKitPlugin.PluginId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> emptyState() {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<Command, String, Set<String>> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(
|
||||
StoreMe.class,
|
||||
(StoreMe cmd) -> Effect().persist(cmd.text).thenRun(__ -> cmd.replyTo.tell(done())))
|
||||
.onCommand(
|
||||
StoreUs.class,
|
||||
(StoreUs cmd) -> Effect().persist(cmd.texts).thenRun(__ -> cmd.replyTo.tell(done())))
|
||||
.onCommand(
|
||||
GetState.class,
|
||||
(GetState get) ->
|
||||
Effect()
|
||||
.none()
|
||||
.thenRun(state -> get.replyTo.tell(new State(new HashSet<>(state)))))
|
||||
.onCommand(
|
||||
GetReplica.class,
|
||||
(GetReplica cmd) ->
|
||||
Effect()
|
||||
.none()
|
||||
.thenRun(() -> cmd.replyTo.tell(getReplicationContext().replicaId())))
|
||||
.onCommand(Stop.class, __ -> Effect().stop())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<Set<String>, String> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onAnyEvent(
|
||||
(state, text) -> {
|
||||
// FIXME mutable - state I don't remember if we support or not so defensive copy for
|
||||
// now
|
||||
Set<String> newSet = new HashSet<>(state);
|
||||
newSet.add(text);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ClassRule
|
||||
public static final TestKitJunitResource testKit =
|
||||
new TestKitJunitResource(
|
||||
ConfigFactory.parseString(
|
||||
"pekko.loglevel = INFO\n"
|
||||
+ "pekko.loggers = [\"org.apache.pekko.testkit.TestEventListener\"]")
|
||||
.withFallback(PersistenceTestKitPlugin.getInstance().config()));
|
||||
|
||||
@Rule public final LogCapturing logCapturing = new LogCapturing();
|
||||
|
||||
// minimal test, full coverage over in ReplicatedEventSourcingSpec
|
||||
@Test
|
||||
public void replicatedEventSourcingReplicationTest() {
|
||||
ReplicaId dcA = new ReplicaId("DC-A");
|
||||
ReplicaId dcB = new ReplicaId("DC-B");
|
||||
ReplicaId dcC = new ReplicaId("DC-C");
|
||||
Set<ReplicaId> allReplicas = new HashSet<>(Arrays.asList(dcA, dcB, dcC));
|
||||
|
||||
ActorRef<TestBehavior.Command> replicaA =
|
||||
testKit.spawn(TestBehavior.create("id1", dcA, allReplicas));
|
||||
ActorRef<TestBehavior.Command> replicaB =
|
||||
testKit.spawn(TestBehavior.create("id1", dcB, allReplicas));
|
||||
ActorRef<TestBehavior.Command> replicaC =
|
||||
testKit.spawn(TestBehavior.create("id1", dcC, allReplicas));
|
||||
|
||||
TestProbe<Object> probe = testKit.createTestProbe();
|
||||
replicaA.tell(new TestBehavior.GetReplica(probe.ref().narrow()));
|
||||
assertEquals("DC-A", probe.expectMessageClass(ReplicaId.class).id());
|
||||
|
||||
replicaA.tell(new TestBehavior.StoreMe("stored-to-a", probe.ref().narrow()));
|
||||
replicaB.tell(new TestBehavior.StoreMe("stored-to-b", probe.ref().narrow()));
|
||||
replicaC.tell(new TestBehavior.StoreMe("stored-to-c", probe.ref().narrow()));
|
||||
probe.receiveSeveralMessages(3);
|
||||
|
||||
probe.awaitAssert(
|
||||
() -> {
|
||||
replicaA.tell(new TestBehavior.GetState(probe.ref().narrow()));
|
||||
TestBehavior.State reply = probe.expectMessageClass(TestBehavior.State.class);
|
||||
assertEquals(
|
||||
new HashSet<>(Arrays.asList("stored-to-a", "stored-to-b", "stored-to-c")),
|
||||
reply.texts);
|
||||
return null;
|
||||
});
|
||||
probe.awaitAssert(
|
||||
() -> {
|
||||
replicaB.tell(new TestBehavior.GetState(probe.ref().narrow()));
|
||||
TestBehavior.State reply = probe.expectMessageClass(TestBehavior.State.class);
|
||||
assertEquals(
|
||||
new HashSet<>(Arrays.asList("stored-to-a", "stored-to-b", "stored-to-c")),
|
||||
reply.texts);
|
||||
return null;
|
||||
});
|
||||
probe.awaitAssert(
|
||||
() -> {
|
||||
replicaC.tell(new TestBehavior.GetState(probe.ref().narrow()));
|
||||
TestBehavior.State reply = probe.expectMessageClass(TestBehavior.State.class);
|
||||
assertEquals(
|
||||
new HashSet<>(Arrays.asList("stored-to-a", "stored-to-b", "stored-to-c")),
|
||||
reply.texts);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Copyright (C) 2018-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.javadsl;
|
||||
|
||||
import org.apache.pekko.actor.testkit.typed.TestException;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.LogCapturing;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestKitJunitResource;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestProbe;
|
||||
import org.apache.pekko.actor.typed.ActorRef;
|
||||
import org.apache.pekko.actor.typed.Behavior;
|
||||
import org.apache.pekko.actor.typed.SupervisorStrategy;
|
||||
import org.apache.pekko.persistence.typed.PersistenceId;
|
||||
import org.apache.pekko.persistence.typed.RecoveryCompleted;
|
||||
import org.apache.pekko.persistence.typed.RecoveryFailed;
|
||||
import com.typesafe.config.Config;
|
||||
import com.typesafe.config.ConfigFactory;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.scalatestplus.junit.JUnitSuite;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.apache.pekko.persistence.typed.scaladsl.EventSourcedBehaviorFailureSpec.conf;
|
||||
|
||||
class FailingEventSourcedActor extends EventSourcedBehavior<String, String, String> {
|
||||
|
||||
private final ActorRef<String> probe;
|
||||
private final ActorRef<Throwable> recoveryFailureProbe;
|
||||
|
||||
FailingEventSourcedActor(
|
||||
PersistenceId persistenceId,
|
||||
ActorRef<String> probe,
|
||||
ActorRef<Throwable> recoveryFailureProbe) {
|
||||
|
||||
super(
|
||||
persistenceId,
|
||||
SupervisorStrategy.restartWithBackoff(Duration.ofMillis(1), Duration.ofMillis(5), 0.1));
|
||||
this.probe = probe;
|
||||
this.recoveryFailureProbe = recoveryFailureProbe;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignalHandler<String> signalHandler() {
|
||||
return newSignalHandlerBuilder()
|
||||
.onSignal(
|
||||
RecoveryCompleted.instance(),
|
||||
state -> {
|
||||
probe.tell("starting");
|
||||
})
|
||||
.onSignal(
|
||||
RecoveryFailed.class,
|
||||
(state, signal) -> {
|
||||
recoveryFailureProbe.tell(signal.getFailure());
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String emptyState() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<String, String, String> commandHandler() {
|
||||
return (state, command) -> {
|
||||
probe.tell("persisting");
|
||||
return Effect().persist(command);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<String, String> eventHandler() {
|
||||
return (state, event) -> {
|
||||
probe.tell(event);
|
||||
return state + event;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class EventSourcedActorFailureTest extends JUnitSuite {
|
||||
|
||||
public static final Config config = conf().withFallback(ConfigFactory.load());
|
||||
|
||||
@ClassRule public static final TestKitJunitResource testKit = new TestKitJunitResource(config);
|
||||
|
||||
@Rule public final LogCapturing logCapturing = new LogCapturing();
|
||||
|
||||
public static Behavior<String> fail(
|
||||
PersistenceId pid, ActorRef<String> probe, ActorRef<Throwable> recoveryFailureProbe) {
|
||||
return new FailingEventSourcedActor(pid, probe, recoveryFailureProbe);
|
||||
}
|
||||
|
||||
public static Behavior<String> fail(PersistenceId pid, ActorRef<String> probe) {
|
||||
return fail(pid, probe, testKit.<Throwable>createTestProbe().ref());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void notifyRecoveryFailure() {
|
||||
TestProbe<String> probe = testKit.createTestProbe();
|
||||
TestProbe<Throwable> recoveryFailureProbe = testKit.createTestProbe();
|
||||
Behavior<String> p1 =
|
||||
fail(
|
||||
PersistenceId.ofUniqueId("fail-recovery-once"),
|
||||
probe.ref(),
|
||||
recoveryFailureProbe.ref());
|
||||
testKit.spawn(p1);
|
||||
recoveryFailureProbe.expectMessageClass(TestException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistEvents() throws Exception {
|
||||
TestProbe<String> probe = testKit.createTestProbe();
|
||||
Behavior<String> p1 = fail(PersistenceId.ofUniqueId("fail-first-2"), probe.ref());
|
||||
ActorRef<String> c = testKit.spawn(p1);
|
||||
probe.expectMessage("starting");
|
||||
// fail
|
||||
c.tell("one");
|
||||
probe.expectMessage("persisting");
|
||||
probe.expectMessage("one");
|
||||
probe.expectMessage("starting");
|
||||
// fail
|
||||
c.tell("two");
|
||||
probe.expectMessage("persisting");
|
||||
probe.expectMessage("two");
|
||||
probe.expectMessage("starting");
|
||||
// work
|
||||
c.tell("three");
|
||||
probe.expectMessage("persisting");
|
||||
probe.expectMessage("three");
|
||||
// no starting as this one did not fail
|
||||
probe.expectNoMessage();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,786 @@
|
|||
/*
|
||||
* Copyright (C) 2018-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.javadsl;
|
||||
|
||||
import org.apache.pekko.Done;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.LogCapturing;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.LoggingTestKit;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestKitJunitResource;
|
||||
import org.apache.pekko.actor.testkit.typed.javadsl.TestProbe;
|
||||
import org.apache.pekko.actor.typed.*;
|
||||
import org.apache.pekko.actor.typed.javadsl.ActorContext;
|
||||
import org.apache.pekko.actor.typed.javadsl.Adapter;
|
||||
import org.apache.pekko.actor.typed.javadsl.Behaviors;
|
||||
import org.apache.pekko.japi.Pair;
|
||||
import org.apache.pekko.persistence.query.EventEnvelope;
|
||||
import org.apache.pekko.persistence.query.NoOffset;
|
||||
import org.apache.pekko.persistence.query.PersistenceQuery;
|
||||
import org.apache.pekko.persistence.query.Sequence;
|
||||
import org.apache.pekko.persistence.testkit.PersistenceTestKitPlugin;
|
||||
import org.apache.pekko.persistence.testkit.PersistenceTestKitSnapshotPlugin;
|
||||
import org.apache.pekko.persistence.testkit.query.javadsl.PersistenceTestKitReadJournal;
|
||||
import org.apache.pekko.persistence.typed.*;
|
||||
import org.apache.pekko.serialization.jackson.CborSerializable;
|
||||
import org.apache.pekko.stream.javadsl.Sink;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.google.common.collect.Sets;
|
||||
import com.typesafe.config.ConfigFactory;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.scalatestplus.junit.JUnitSuite;
|
||||
import org.slf4j.event.Level;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
import static org.apache.pekko.Done.done;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class EventSourcedBehaviorJavaDslTest extends JUnitSuite {
|
||||
|
||||
@ClassRule
|
||||
public static final TestKitJunitResource testKit =
|
||||
new TestKitJunitResource(
|
||||
ConfigFactory.parseString(
|
||||
"pekko.loglevel = INFO\n"
|
||||
+ "pekko.loggers = [\"org.apache.pekko.testkit.TestEventListener\"]")
|
||||
.withFallback(PersistenceTestKitPlugin.getInstance().config())
|
||||
.withFallback(PersistenceTestKitSnapshotPlugin.config()));
|
||||
|
||||
@Rule public final LogCapturing logCapturing = new LogCapturing();
|
||||
|
||||
private PersistenceTestKitReadJournal queries =
|
||||
PersistenceQuery.get(Adapter.toClassic(testKit.system()))
|
||||
.getReadJournalFor(
|
||||
PersistenceTestKitReadJournal.class, PersistenceTestKitReadJournal.Identifier());
|
||||
|
||||
interface Command extends CborSerializable {}
|
||||
|
||||
public enum Increment implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public enum Increment100OnTimeout implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public enum IncrementLater implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public enum DelayFinished implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public enum EmptyEventsListAndThenLog implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public enum IncrementTwiceAndLog implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public static class IncrementWithConfirmation implements Command {
|
||||
public final ActorRef<Done> replyTo;
|
||||
|
||||
@JsonCreator
|
||||
public IncrementWithConfirmation(ActorRef<Done> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
public enum StopThenLog implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public enum Timeout implements Command {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
public static class GetValue implements Command {
|
||||
public final ActorRef<State> replyTo;
|
||||
|
||||
@JsonCreator
|
||||
public GetValue(ActorRef<State> replyTo) {
|
||||
this.replyTo = replyTo;
|
||||
}
|
||||
}
|
||||
|
||||
// need the JsonTypeInfo because of the Wrapper
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({@JsonSubTypes.Type(value = Incremented.class, name = "incremented")})
|
||||
interface Event extends CborSerializable {}
|
||||
|
||||
public static class Incremented implements Event {
|
||||
final int delta;
|
||||
|
||||
@JsonCreator
|
||||
public Incremented(int delta) {
|
||||
this.delta = delta;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Incremented{" + "delta=" + delta + '}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
Incremented that = (Incremented) o;
|
||||
return delta == that.delta;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
|
||||
return Objects.hash(delta);
|
||||
}
|
||||
}
|
||||
|
||||
public static class State implements CborSerializable {
|
||||
final int value;
|
||||
final List<Integer> history;
|
||||
|
||||
static final State EMPTY = new State(0, Collections.emptyList());
|
||||
|
||||
public State(int value, List<Integer> history) {
|
||||
this.value = value;
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
State incrementedBy(int delta) {
|
||||
List<Integer> newHistory = new ArrayList<>(history);
|
||||
newHistory.add(value);
|
||||
return new State(value + delta, newHistory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "State{" + "value=" + value + ", history=" + history + '}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
State state = (State) o;
|
||||
return value == state.value && Objects.equals(history, state.history);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
|
||||
return Objects.hash(value, history);
|
||||
}
|
||||
}
|
||||
|
||||
public enum Tick {
|
||||
INSTANCE
|
||||
}
|
||||
|
||||
private Behavior<Command> counter(PersistenceId persistenceId) {
|
||||
return Behaviors.setup(ctx -> new CounterBehavior(persistenceId, ctx));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static class CounterBehavior extends EventSourcedBehavior<Command, Event, State> {
|
||||
private final ActorContext<Command> ctx;
|
||||
|
||||
CounterBehavior(PersistenceId persistenceId, ActorContext<Command> ctx) {
|
||||
super(
|
||||
persistenceId,
|
||||
SupervisorStrategy.restartWithBackoff(Duration.ofMillis(1), Duration.ofMillis(5), 0.1));
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<Command, Event, State> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(Increment.class, this::increment)
|
||||
.onCommand(IncrementWithConfirmation.class, this::incrementWithConfirmation)
|
||||
.onCommand(GetValue.class, this::getValue)
|
||||
.onCommand(IncrementLater.class, this::incrementLater)
|
||||
.onCommand(DelayFinished.class, this::delayFinished)
|
||||
.onCommand(Increment100OnTimeout.class, this::increment100OnTimeout)
|
||||
.onCommand(Timeout.class, this::timeout)
|
||||
.onCommand(EmptyEventsListAndThenLog.class, this::emptyEventsListAndThenLog)
|
||||
.onCommand(StopThenLog.class, this::stopThenLog)
|
||||
.onCommand(IncrementTwiceAndLog.class, this::incrementTwiceAndLog)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Effect<Event, State> increment(State state, Increment command) {
|
||||
return Effect().persist(new Incremented(1));
|
||||
}
|
||||
|
||||
private ReplyEffect<Event, State> incrementWithConfirmation(
|
||||
State state, IncrementWithConfirmation command) {
|
||||
return Effect().persist(new Incremented(1)).thenReply(command.replyTo, newState -> done());
|
||||
}
|
||||
|
||||
private ReplyEffect<Event, State> getValue(State state, GetValue command) {
|
||||
return Effect().reply(command.replyTo, state);
|
||||
}
|
||||
|
||||
private Effect<Event, State> incrementLater(State state, IncrementLater command) {
|
||||
ActorRef<Object> delay =
|
||||
ctx.spawnAnonymous(
|
||||
Behaviors.withTimers(
|
||||
timers -> {
|
||||
timers.startSingleTimer(Tick.INSTANCE, Tick.INSTANCE, Duration.ofMillis(10));
|
||||
return Behaviors.receive((context, o) -> Behaviors.stopped());
|
||||
}));
|
||||
ctx.watchWith(delay, DelayFinished.INSTANCE);
|
||||
return Effect().none();
|
||||
}
|
||||
|
||||
private Effect<Event, State> delayFinished(State state, DelayFinished command) {
|
||||
return Effect().persist(new Incremented(10));
|
||||
}
|
||||
|
||||
private Effect<Event, State> increment100OnTimeout(State state, Increment100OnTimeout command) {
|
||||
ctx.setReceiveTimeout(Duration.ofMillis(10), Timeout.INSTANCE);
|
||||
return Effect().none();
|
||||
}
|
||||
|
||||
private Effect<Event, State> timeout(State state, Timeout command) {
|
||||
return Effect().persist(new Incremented(100));
|
||||
}
|
||||
|
||||
private Effect<Event, State> emptyEventsListAndThenLog(
|
||||
State state, EmptyEventsListAndThenLog command) {
|
||||
return Effect().persist(Collections.emptyList()).thenRun(s -> log());
|
||||
}
|
||||
|
||||
private Effect<Event, State> stopThenLog(State state, StopThenLog command) {
|
||||
return Effect().stop().thenRun(s -> log());
|
||||
}
|
||||
|
||||
private Effect<Event, State> incrementTwiceAndLog(State state, IncrementTwiceAndLog command) {
|
||||
return Effect()
|
||||
.persist(Arrays.asList(new Incremented(1), new Incremented(1)))
|
||||
.thenRun(s -> log());
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<State, Event> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onEvent(Incremented.class, this::applyIncremented)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public State emptyState() {
|
||||
return State.EMPTY;
|
||||
}
|
||||
|
||||
protected State applyIncremented(State state, Incremented event) {
|
||||
// override to probe for events
|
||||
return state.incrementedBy(event.delta);
|
||||
}
|
||||
|
||||
protected void log() {
|
||||
// override to probe for logs
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistEvents() {
|
||||
ActorRef<Command> c = testKit.spawn(counter(PersistenceId.ofUniqueId("c1")));
|
||||
TestProbe<State> probe = testKit.createTestProbe();
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(new GetValue(probe.ref()));
|
||||
probe.expectMessage(new State(1, singletonList(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replyStoredEvents() {
|
||||
ActorRef<Command> c = testKit.spawn(counter(PersistenceId.ofUniqueId("c2")));
|
||||
TestProbe<State> probe = testKit.createTestProbe();
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(new GetValue(probe.ref()));
|
||||
probe.expectMessage(new State(3, Arrays.asList(0, 1, 2)));
|
||||
|
||||
ActorRef<Command> c2 = testKit.spawn(counter(PersistenceId.ofUniqueId("c2")));
|
||||
c2.tell(new GetValue(probe.ref()));
|
||||
probe.expectMessage(new State(3, Arrays.asList(0, 1, 2)));
|
||||
c2.tell(Increment.INSTANCE);
|
||||
c2.tell(new GetValue(probe.ref()));
|
||||
probe.expectMessage(new State(4, Arrays.asList(0, 1, 2, 3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void thenReplyEffect() {
|
||||
ActorRef<Command> c = testKit.spawn(counter(PersistenceId.ofUniqueId("c1b")));
|
||||
TestProbe<Done> probe = testKit.createTestProbe();
|
||||
c.tell(new IncrementWithConfirmation(probe.ref()));
|
||||
probe.expectMessage(Done.getInstance());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void handleTerminatedSignal() {
|
||||
TestProbe<Pair<State, Incremented>> eventHandlerProbe = testKit.createTestProbe();
|
||||
Behavior<Command> counter =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("c3"), ctx) {
|
||||
@Override
|
||||
protected State applyIncremented(State state, Incremented event) {
|
||||
eventHandlerProbe.ref().tell(Pair.create(state, event));
|
||||
return super.applyIncremented(state, event);
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(counter);
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(IncrementLater.INSTANCE);
|
||||
eventHandlerProbe.expectMessage(Pair.create(State.EMPTY, new Incremented(1)));
|
||||
eventHandlerProbe.expectMessage(
|
||||
Pair.create(new State(1, Collections.singletonList(0)), new Incremented(10)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void handleReceiveTimeout() {
|
||||
TestProbe<Pair<State, Incremented>> eventHandlerProbe = testKit.createTestProbe();
|
||||
Behavior<Command> counter =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("c4"), ctx) {
|
||||
@Override
|
||||
protected State applyIncremented(State state, Incremented event) {
|
||||
eventHandlerProbe.ref().tell(Pair.create(state, event));
|
||||
return super.applyIncremented(state, event);
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(counter);
|
||||
c.tell(Increment100OnTimeout.INSTANCE);
|
||||
eventHandlerProbe.expectMessage(Pair.create(State.EMPTY, new Incremented(100)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chainableSideEffectsWithEvents() {
|
||||
TestProbe<String> loggingProbe = testKit.createTestProbe();
|
||||
Behavior<Command> counter =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("c5"), ctx) {
|
||||
@Override
|
||||
protected void log() {
|
||||
loggingProbe.ref().tell("logged");
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(counter);
|
||||
c.tell(EmptyEventsListAndThenLog.INSTANCE);
|
||||
loggingProbe.expectMessage("logged");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void workWhenWrappedInOtherBehavior() {
|
||||
Behavior<Command> behavior =
|
||||
Behaviors.supervise(counter(PersistenceId.ofUniqueId("c6")))
|
||||
.onFailure(
|
||||
SupervisorStrategy.restartWithBackoff(
|
||||
Duration.ofSeconds(1), Duration.ofSeconds(10), 0.1));
|
||||
ActorRef<Command> c = testKit.spawn(behavior);
|
||||
|
||||
TestProbe<State> probe = testKit.createTestProbe();
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(new GetValue(probe.ref()));
|
||||
probe.expectMessage(new State(1, singletonList(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void snapshot() {
|
||||
TestProbe<Optional<Throwable>> snapshotProbe = testKit.createTestProbe();
|
||||
|
||||
Behavior<Command> snapshoter =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("snapshot"), ctx) {
|
||||
@Override
|
||||
public boolean shouldSnapshot(State state, Event event, long sequenceNr) {
|
||||
return state.value % 2 == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignalHandler<State> signalHandler() {
|
||||
return newSignalHandlerBuilder()
|
||||
.onSignal(
|
||||
SnapshotCompleted.class,
|
||||
(state, completed) -> {
|
||||
snapshotProbe.ref().tell(Optional.empty());
|
||||
})
|
||||
.onSignal(
|
||||
SnapshotFailed.class,
|
||||
(state, signal) -> {
|
||||
snapshotProbe.ref().tell(Optional.of(signal.getFailure()));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(snapshoter);
|
||||
c.tell(Increment.INSTANCE);
|
||||
c.tell(Increment.INSTANCE);
|
||||
snapshotProbe.expectMessage(Optional.empty());
|
||||
c.tell(Increment.INSTANCE);
|
||||
|
||||
TestProbe<State> stateProbe = testKit.createTestProbe();
|
||||
c.tell(new GetValue(stateProbe.ref()));
|
||||
stateProbe.expectMessage(new State(3, Arrays.asList(0, 1, 2)));
|
||||
|
||||
TestProbe<Pair<State, Incremented>> eventHandlerProbe = testKit.createTestProbe();
|
||||
Behavior<Command> recovered =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("snapshot"), ctx) {
|
||||
@Override
|
||||
protected State applyIncremented(State state, Incremented event) {
|
||||
eventHandlerProbe.ref().tell(Pair.create(state, event));
|
||||
return super.applyIncremented(state, event);
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c2 = testKit.spawn(recovered);
|
||||
// First 2 are snapshot
|
||||
eventHandlerProbe.expectMessage(
|
||||
Pair.create(new State(2, Arrays.asList(0, 1)), new Incremented(1)));
|
||||
c2.tell(new GetValue(stateProbe.ref()));
|
||||
stateProbe.expectMessage(new State(3, Arrays.asList(0, 1, 2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stopThenLog() {
|
||||
TestProbe<State> probe = testKit.createTestProbe();
|
||||
ActorRef<Command> c = testKit.spawn(counter(PersistenceId.ofUniqueId("c12")));
|
||||
c.tell(StopThenLog.INSTANCE);
|
||||
probe.expectTerminated(c);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void postStop() {
|
||||
TestProbe<String> probe = testKit.createTestProbe();
|
||||
Behavior<Command> counter =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("c5"), ctx) {
|
||||
|
||||
@Override
|
||||
public SignalHandler<State> signalHandler() {
|
||||
return newSignalHandlerBuilder()
|
||||
.onSignal(
|
||||
PostStop.instance(),
|
||||
state -> {
|
||||
probe.ref().tell("stopped");
|
||||
})
|
||||
.build();
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(counter);
|
||||
c.tell(StopThenLog.INSTANCE);
|
||||
probe.expectMessage("stopped");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapPersistentActor() {
|
||||
TestProbe<Object> interceptProbe = testKit.createTestProbe();
|
||||
TestProbe<Signal> signalProbe = testKit.createTestProbe();
|
||||
BehaviorInterceptor<Command, Command> tap =
|
||||
new BehaviorInterceptor<Command, Command>(Command.class) {
|
||||
|
||||
@Override
|
||||
public Behavior<Command> aroundReceive(
|
||||
TypedActorContext<Command> ctx, Command msg, ReceiveTarget<Command> target) {
|
||||
interceptProbe.ref().tell(msg);
|
||||
return target.apply(ctx, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Behavior<Command> aroundSignal(
|
||||
TypedActorContext<Command> ctx, Signal signal, SignalTarget<Command> target) {
|
||||
signalProbe.ref().tell(signal);
|
||||
return target.apply(ctx, signal);
|
||||
}
|
||||
};
|
||||
ActorRef<Command> c =
|
||||
testKit.spawn(Behaviors.intercept(() -> tap, counter(PersistenceId.ofUniqueId("tap1"))));
|
||||
c.tell(Increment.INSTANCE);
|
||||
interceptProbe.expectMessage(Increment.INSTANCE);
|
||||
signalProbe.expectNoMessage();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tagEvent() throws Exception {
|
||||
Behavior<Command> tagger =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("tagging"), ctx) {
|
||||
@Override
|
||||
public Set<String> tagsFor(Event incremented) {
|
||||
return Sets.newHashSet("tag1", "tag2");
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(tagger);
|
||||
|
||||
c.tell(Increment.INSTANCE);
|
||||
|
||||
TestProbe<State> stateProbe = testKit.createTestProbe();
|
||||
c.tell(new GetValue(stateProbe.ref()));
|
||||
stateProbe.expectMessage(new State(1, Collections.singletonList(0)));
|
||||
|
||||
List<EventEnvelope> events =
|
||||
queries
|
||||
.currentEventsByTag("tag1", NoOffset.getInstance())
|
||||
.runWith(Sink.seq(), testKit.system())
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
assertEquals(1, events.size());
|
||||
EventEnvelope eventEnvelope = events.get(0);
|
||||
assertEquals(new Sequence(1), eventEnvelope.offset());
|
||||
assertEquals("tagging", eventEnvelope.persistenceId());
|
||||
assertEquals(new Incremented(1), eventEnvelope.event());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transformEvent() throws Exception {
|
||||
Behavior<Command> transformer =
|
||||
Behaviors.setup(
|
||||
ctx ->
|
||||
new CounterBehavior(PersistenceId.ofUniqueId("transform"), ctx) {
|
||||
private final EventAdapter<Event, ?> adapter = new WrapperEventAdapter();
|
||||
|
||||
public EventAdapter<Event, ?> eventAdapter() {
|
||||
return adapter;
|
||||
}
|
||||
});
|
||||
ActorRef<Command> c = testKit.spawn(transformer);
|
||||
|
||||
c.tell(Increment.INSTANCE);
|
||||
|
||||
TestProbe<State> stateProbe = testKit.createTestProbe();
|
||||
c.tell(new GetValue(stateProbe.ref()));
|
||||
stateProbe.expectMessage(new State(1, Collections.singletonList(0)));
|
||||
|
||||
List<EventEnvelope> events =
|
||||
queries
|
||||
.currentEventsByPersistenceId("transform", 0, Long.MAX_VALUE)
|
||||
.runWith(Sink.seq(), testKit.system())
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
assertEquals(1, events.size());
|
||||
EventEnvelope eventEnvelope = events.get(0);
|
||||
assertEquals(new Sequence(1), eventEnvelope.offset());
|
||||
assertEquals("transform", eventEnvelope.persistenceId());
|
||||
assertEquals(new Wrapper(new Incremented(1)), eventEnvelope.event());
|
||||
|
||||
ActorRef<Command> c2 = testKit.spawn(transformer);
|
||||
c2.tell(new GetValue(stateProbe.ref()));
|
||||
stateProbe.expectMessage(new State(1, Collections.singletonList(0)));
|
||||
}
|
||||
|
||||
// event-wrapper
|
||||
public static class Wrapper implements CborSerializable {
|
||||
private final Event event;
|
||||
|
||||
@JsonCreator
|
||||
public Wrapper(Event event) {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
public Event getEvent() {
|
||||
return event;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Wrapper wrapper = (Wrapper) o;
|
||||
|
||||
return event.equals(wrapper.event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return event.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Wrapper(" + event + ")";
|
||||
}
|
||||
}
|
||||
|
||||
class WrapperEventAdapter extends EventAdapter<Event, Wrapper> {
|
||||
@Override
|
||||
public Wrapper toJournal(Event event) {
|
||||
return new Wrapper(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String manifest(Event event) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventSeq<Event> fromJournal(Wrapper wrapper, String manifest) {
|
||||
return EventSeq.single(wrapper.getEvent());
|
||||
}
|
||||
}
|
||||
// event-wrapper
|
||||
|
||||
static class IncorrectExpectedStateForThenRun
|
||||
extends EventSourcedBehavior<String, String, Object> {
|
||||
|
||||
private final ActorRef<String> startedProbe;
|
||||
|
||||
public IncorrectExpectedStateForThenRun(
|
||||
ActorRef<String> startedProbe, PersistenceId persistenceId) {
|
||||
super(persistenceId);
|
||||
this.startedProbe = startedProbe;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object emptyState() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<String, String, Object> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onCommand(
|
||||
msg -> msg.equals("expect wrong type"),
|
||||
(context) ->
|
||||
Effect()
|
||||
.none()
|
||||
.thenRun(
|
||||
(String wrongType) -> {
|
||||
// wont happen
|
||||
}))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignalHandler<Object> signalHandler() {
|
||||
return newSignalHandlerBuilder()
|
||||
.onSignal(
|
||||
RecoveryCompleted.class,
|
||||
(state, completed) -> {
|
||||
startedProbe.tell("started!");
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<Object, String> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onAnyEvent((event, state) -> state); // keep Integer state
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failOnIncorrectExpectedStateForThenRun() {
|
||||
TestProbe<String> probe = testKit.createTestProbe();
|
||||
ActorRef<String> c =
|
||||
testKit.spawn(
|
||||
new IncorrectExpectedStateForThenRun(
|
||||
probe.getRef(), PersistenceId.ofUniqueId("foiesftr")));
|
||||
|
||||
probe.expectMessage("started!");
|
||||
|
||||
LoggingTestKit.empty()
|
||||
.withLogLevel(Level.ERROR)
|
||||
// the error messages slightly changed in later JDKs
|
||||
.withMessageRegex(
|
||||
"(class )?java.lang.Integer cannot be cast to (class )?java.lang.String.*")
|
||||
.expect(
|
||||
testKit.system(),
|
||||
() -> {
|
||||
c.tell("expect wrong type");
|
||||
return null;
|
||||
});
|
||||
|
||||
probe.expectTerminated(c);
|
||||
}
|
||||
|
||||
class SequenceNumberBehavior extends EventSourcedBehavior<String, String, String> {
|
||||
private final ActorRef<String> probe;
|
||||
private final ActorContext<String> context;
|
||||
|
||||
public SequenceNumberBehavior(
|
||||
PersistenceId persistenceId, ActorRef<String> probe, ActorContext<String> context) {
|
||||
super(persistenceId);
|
||||
this.probe = probe;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String emptyState() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandHandler<String, String, String> commandHandler() {
|
||||
return newCommandHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onAnyCommand(
|
||||
(state, cmd) -> {
|
||||
probe.tell(lastSequenceNumber(context) + " onCommand");
|
||||
return Effect()
|
||||
.persist(cmd)
|
||||
.thenRun((newState) -> probe.tell(lastSequenceNumber(context) + " thenRun"));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventHandler<String, String> eventHandler() {
|
||||
return newEventHandlerBuilder()
|
||||
.forAnyState()
|
||||
.onAnyEvent(
|
||||
(state, event) -> {
|
||||
probe.tell(lastSequenceNumber(context) + " applyEvent");
|
||||
return state + event;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignalHandler<String> signalHandler() {
|
||||
return newSignalHandlerBuilder()
|
||||
.onSignal(
|
||||
RecoveryCompleted.class,
|
||||
(state, completed) -> {
|
||||
probe.tell(lastSequenceNumber(context) + " onRecoveryCompleted");
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessLastSequenceNumber() {
|
||||
TestProbe<String> probe = testKit.createTestProbe(String.class);
|
||||
ActorRef<String> ref =
|
||||
testKit.spawn(
|
||||
Behaviors.<String>setup(
|
||||
context ->
|
||||
new SequenceNumberBehavior(
|
||||
PersistenceId.ofUniqueId("seqnr1"), probe.getRef(), context)));
|
||||
|
||||
probe.expectMessage("0 onRecoveryCompleted");
|
||||
ref.tell("cmd");
|
||||
probe.expectMessage("0 onCommand");
|
||||
probe.expectMessage("1 applyEvent");
|
||||
probe.expectMessage("1 thenRun");
|
||||
}
|
||||
}
|
||||
32
persistence-typed-tests/src/test/resources/logback-test.xml
Normal file
32
persistence-typed-tests/src/test/resources/logback-test.xml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- Silence initial setup logging from Logback -->
|
||||
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%date{ISO8601} [%-5level] [%logger] [%marker] [%X{akkaSource}] [%X{persistencePhase}] [%X{persistenceId}] - %msg %n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!--
|
||||
Logging from tests are silenced by this appender. When there is a test failure
|
||||
the captured logging events are flushed to the appenders defined for the
|
||||
org.apache.pekko.actor.testkit.typed.internal.CapturingAppenderDelegate logger.
|
||||
-->
|
||||
<appender name="CapturingAppender" class="org.apache.pekko.actor.testkit.typed.internal.CapturingAppender" />
|
||||
|
||||
<!--
|
||||
The appenders defined for this CapturingAppenderDelegate logger are used
|
||||
when there is a test failure and all logging events from the test are
|
||||
flushed to these appenders.
|
||||
-->
|
||||
<logger name="org.apache.pekko.actor.testkit.typed.internal.CapturingAppenderDelegate" >
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</logger>
|
||||
|
||||
<root level="TRACE">
|
||||
<appender-ref ref="CapturingAppender"/>
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package docs.org.apache.pekko.persistence.typed
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import docs.org.apache.pekko.persistence.typed.ReplicatedAuctionExampleSpec.AuctionEntity
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.actor.typed.scaladsl.LoggerOps
|
||||
import pekko.actor.typed.scaladsl.TimerScheduler
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import pekko.persistence.typed.scaladsl.ReplicationContext
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
|
||||
object ReplicatedAuctionExampleSpec {
|
||||
|
||||
// #setup
|
||||
object AuctionEntity {
|
||||
|
||||
// #setup
|
||||
|
||||
// #commands
|
||||
type MoneyAmount = Int
|
||||
|
||||
case class Bid(bidder: String, offer: MoneyAmount, timestamp: Instant, originReplica: ReplicaId)
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
case object Finish extends Command // A timer needs to schedule this event at each replica
|
||||
final case class OfferBid(bidder: String, offer: MoneyAmount) extends Command
|
||||
final case class GetHighestBid(replyTo: ActorRef[Bid]) extends Command
|
||||
final case class IsClosed(replyTo: ActorRef[Boolean]) extends Command
|
||||
private case object Close extends Command // Internal, should not be sent from the outside
|
||||
// #commands
|
||||
|
||||
// #events
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class BidRegistered(bid: Bid) extends Event
|
||||
final case class AuctionFinished(atReplica: ReplicaId) extends Event
|
||||
final case class WinnerDecided(atReplica: ReplicaId, winningBid: Bid, highestCounterOffer: MoneyAmount)
|
||||
extends Event
|
||||
// #events
|
||||
|
||||
// #phase
|
||||
/**
|
||||
* The auction passes through several workflow phases.
|
||||
* First, in `Running` `OfferBid` commands are accepted.
|
||||
*
|
||||
* `AuctionEntity` instances in all DCs schedule a `Finish` command
|
||||
* at a given time. That persists the `AuctionFinished` event and the
|
||||
* phase is in `Closing` until the auction is finished in all DCs.
|
||||
*
|
||||
* When the auction has been finished no more `OfferBid` commands are accepted.
|
||||
*
|
||||
* The auction is also finished immediately if `AuctionFinished` event from another
|
||||
* DC is seen before the scheduled `Finish` command. In that way the auction is finished
|
||||
* as quickly as possible in all DCs even though there might be some clock skew.
|
||||
*
|
||||
* One DC is responsible for finally deciding the winner and publishing the result.
|
||||
* All events must be collected from all DC before that can happen.
|
||||
* When the responsible DC has seen all `AuctionFinished` events from other DCs
|
||||
* all other events have also been propagated and it can persist `WinnerDecided` and
|
||||
* the auction is finally `Closed`.
|
||||
*/
|
||||
sealed trait AuctionPhase
|
||||
case object Running extends AuctionPhase
|
||||
final case class Closing(finishedAtReplica: Set[ReplicaId]) extends AuctionPhase
|
||||
case object Closed extends AuctionPhase
|
||||
// #phase
|
||||
|
||||
// #state
|
||||
case class AuctionState(phase: AuctionPhase, highestBid: Bid, highestCounterOffer: MoneyAmount)
|
||||
extends CborSerializable {
|
||||
|
||||
def applyEvent(event: Event): AuctionState =
|
||||
event match {
|
||||
case BidRegistered(b) =>
|
||||
if (isHigherBid(b, highestBid))
|
||||
withNewHighestBid(b)
|
||||
else
|
||||
withTooLowBid(b)
|
||||
case AuctionFinished(atDc) =>
|
||||
phase match {
|
||||
case Running =>
|
||||
copy(phase = Closing(Set(atDc)))
|
||||
case Closing(alreadyFinishedDcs) =>
|
||||
copy(phase = Closing(alreadyFinishedDcs + atDc))
|
||||
case _ =>
|
||||
this
|
||||
}
|
||||
case _: WinnerDecided =>
|
||||
copy(phase = Closed)
|
||||
}
|
||||
|
||||
def withNewHighestBid(bid: Bid): AuctionState = {
|
||||
require(phase != Closed)
|
||||
require(isHigherBid(bid, highestBid))
|
||||
copy(highestBid = bid, highestCounterOffer = highestBid.offer // keep last highest bid around
|
||||
)
|
||||
}
|
||||
|
||||
def withTooLowBid(bid: Bid): AuctionState = {
|
||||
require(phase != Closed)
|
||||
require(isHigherBid(highestBid, bid))
|
||||
copy(highestCounterOffer = highestCounterOffer.max(bid.offer)) // update highest counter offer
|
||||
}
|
||||
|
||||
def isHigherBid(first: Bid, second: Bid): Boolean =
|
||||
first.offer > second.offer ||
|
||||
(first.offer == second.offer && first.timestamp.isBefore(second.timestamp)) || // if equal, first one wins
|
||||
// If timestamps are equal, choose by dc where the offer was submitted
|
||||
// In real auctions, this last comparison should be deterministic but unpredictable, so that submitting to a
|
||||
// particular DC would not be an advantage.
|
||||
(first.offer == second.offer && first.timestamp.equals(second.timestamp) && first.originReplica.id
|
||||
.compareTo(second.originReplica.id) < 0)
|
||||
}
|
||||
// #state
|
||||
|
||||
// #setup
|
||||
def apply(
|
||||
replica: ReplicaId,
|
||||
name: String,
|
||||
initialBid: AuctionEntity.Bid, // the initial bid is basically the minimum price bidden at start time by the owner
|
||||
closingAt: Instant,
|
||||
responsibleForClosing: Boolean,
|
||||
allReplicas: Set[ReplicaId]): Behavior[Command] = Behaviors.setup[Command] { ctx =>
|
||||
Behaviors.withTimers { timers =>
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("auction", name, replica),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationCtx =>
|
||||
new AuctionEntity(ctx, replicationCtx, timers, closingAt, responsibleForClosing, allReplicas)
|
||||
.behavior(initialBid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AuctionEntity(
|
||||
context: ActorContext[AuctionEntity.Command],
|
||||
replicationContext: ReplicationContext,
|
||||
timers: TimerScheduler[AuctionEntity.Command],
|
||||
closingAt: Instant,
|
||||
responsibleForClosing: Boolean,
|
||||
allReplicas: Set[ReplicaId]) {
|
||||
import AuctionEntity._
|
||||
|
||||
private def behavior(initialBid: AuctionEntity.Bid): EventSourcedBehavior[Command, Event, AuctionState] =
|
||||
EventSourcedBehavior(
|
||||
replicationContext.persistenceId,
|
||||
AuctionState(phase = Running, highestBid = initialBid, highestCounterOffer = initialBid.offer),
|
||||
commandHandler,
|
||||
eventHandler).receiveSignal {
|
||||
case (state, RecoveryCompleted) => recoveryCompleted(state)
|
||||
}
|
||||
|
||||
private def recoveryCompleted(state: AuctionState): Unit = {
|
||||
if (shouldClose(state))
|
||||
context.self ! Close
|
||||
|
||||
val millisUntilClosing = closingAt.toEpochMilli - replicationContext.currentTimeMillis()
|
||||
timers.startSingleTimer(Finish, millisUntilClosing.millis)
|
||||
}
|
||||
// #setup
|
||||
|
||||
// #command-handler
|
||||
def commandHandler(state: AuctionState, command: Command): Effect[Event, AuctionState] = {
|
||||
state.phase match {
|
||||
case Closing(_) | Closed =>
|
||||
command match {
|
||||
case GetHighestBid(replyTo) =>
|
||||
replyTo ! state.highestBid.copy(offer = state.highestCounterOffer) // TODO this is not as described
|
||||
Effect.none
|
||||
case IsClosed(replyTo) =>
|
||||
replyTo ! (state.phase == Closed)
|
||||
Effect.none
|
||||
case Finish =>
|
||||
context.log.info("Finish")
|
||||
Effect.persist(AuctionFinished(replicationContext.replicaId))
|
||||
case Close =>
|
||||
context.log.info("Close")
|
||||
require(shouldClose(state))
|
||||
// TODO send email (before or after persisting)
|
||||
Effect.persist(WinnerDecided(replicationContext.replicaId, state.highestBid, state.highestCounterOffer))
|
||||
case _: OfferBid =>
|
||||
// auction finished, no more bids accepted
|
||||
Effect.unhandled
|
||||
}
|
||||
case Running =>
|
||||
command match {
|
||||
case OfferBid(bidder, offer) =>
|
||||
Effect.persist(
|
||||
BidRegistered(
|
||||
Bid(
|
||||
bidder,
|
||||
offer,
|
||||
Instant.ofEpochMilli(replicationContext.currentTimeMillis()),
|
||||
replicationContext.replicaId)))
|
||||
case GetHighestBid(replyTo) =>
|
||||
replyTo ! state.highestBid
|
||||
Effect.none
|
||||
case Finish =>
|
||||
Effect.persist(AuctionFinished(replicationContext.replicaId))
|
||||
case Close =>
|
||||
context.log.warn("Premature close")
|
||||
// Close should only be triggered when we have already finished
|
||||
Effect.unhandled
|
||||
case IsClosed(replyTo) =>
|
||||
replyTo ! false
|
||||
Effect.none
|
||||
}
|
||||
}
|
||||
}
|
||||
// #command-handler
|
||||
|
||||
// #event-handler
|
||||
def eventHandler(state: AuctionState, event: Event): AuctionState = {
|
||||
|
||||
val newState = state.applyEvent(event)
|
||||
context.log.infoN("Applying event {}. New start {}", event, newState)
|
||||
if (!replicationContext.recoveryRunning) {
|
||||
eventTriggers(event, newState)
|
||||
}
|
||||
newState
|
||||
|
||||
}
|
||||
|
||||
// #event-handler
|
||||
|
||||
// #event-triggers
|
||||
private def eventTriggers(event: Event, newState: AuctionState): Unit = {
|
||||
event match {
|
||||
case finished: AuctionFinished =>
|
||||
newState.phase match {
|
||||
case Closing(alreadyFinishedAtDc) =>
|
||||
context.log.infoN(
|
||||
"AuctionFinished at {}, already finished at [{}]",
|
||||
finished.atReplica,
|
||||
alreadyFinishedAtDc.mkString(", "))
|
||||
if (alreadyFinishedAtDc(replicationContext.replicaId)) {
|
||||
if (shouldClose(newState)) context.self ! Close
|
||||
} else {
|
||||
context.log.info("Sending finish to self")
|
||||
context.self ! Finish
|
||||
}
|
||||
|
||||
case _ => // no trigger for this state
|
||||
}
|
||||
case _ => // no trigger for this event
|
||||
}
|
||||
}
|
||||
|
||||
private def shouldClose(state: AuctionState): Boolean = {
|
||||
responsibleForClosing && (state.phase match {
|
||||
case Closing(alreadyFinishedAtDc) =>
|
||||
val allDone = allReplicas.diff(alreadyFinishedAtDc).isEmpty
|
||||
if (!allDone) {
|
||||
context.log.info2(
|
||||
s"Not closing auction as not all DCs have reported finished. All DCs: {}. Reported finished {}",
|
||||
allReplicas,
|
||||
alreadyFinishedAtDc)
|
||||
}
|
||||
allDone
|
||||
case _ =>
|
||||
false
|
||||
})
|
||||
}
|
||||
// #event-triggers
|
||||
|
||||
// #setup
|
||||
}
|
||||
// #setup
|
||||
}
|
||||
|
||||
class ReplicatedAuctionExampleSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import ReplicatedAuctionExampleSpec.AuctionEntity._
|
||||
|
||||
"Auction example" should {
|
||||
|
||||
"work" in {
|
||||
val Replicas = Set(ReplicaId("DC-A"), ReplicaId("DC-B"))
|
||||
val auctionName = "old-skis"
|
||||
val initialBid = Bid("chbatey", 12, Instant.now(), ReplicaId("DC-A"))
|
||||
val closingAt = Instant.now().plusSeconds(10)
|
||||
|
||||
val dcAReplica: ActorRef[Command] = spawn(
|
||||
AuctionEntity(ReplicaId("DC-A"), auctionName, initialBid, closingAt, responsibleForClosing = true, Replicas))
|
||||
val dcBReplica: ActorRef[Command] = spawn(
|
||||
AuctionEntity(ReplicaId("DC-B"), auctionName, initialBid, closingAt, responsibleForClosing = false, Replicas))
|
||||
|
||||
dcAReplica ! OfferBid("me", 100)
|
||||
dcAReplica ! OfferBid("me", 99)
|
||||
dcAReplica ! OfferBid("me", 202)
|
||||
|
||||
eventually {
|
||||
val replyProbe = createTestProbe[Bid]()
|
||||
dcAReplica ! GetHighestBid(replyProbe.ref)
|
||||
val bid = replyProbe.expectMessageType[Bid]
|
||||
bid.offer shouldEqual 202
|
||||
}
|
||||
|
||||
dcAReplica ! Finish
|
||||
eventually {
|
||||
val finishProbe = createTestProbe[Boolean]()
|
||||
dcAReplica ! IsClosed(finishProbe.ref)
|
||||
finishProbe.expectMessage(true)
|
||||
}
|
||||
eventually {
|
||||
val finishProbe = createTestProbe[Boolean]()
|
||||
dcBReplica ! IsClosed(finishProbe.ref)
|
||||
finishProbe.expectMessage(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package docs.org.apache.pekko.persistence.typed
|
||||
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.crdt.LwwTime
|
||||
import pekko.persistence.typed.scaladsl._
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
|
||||
object ReplicatedBlogExampleSpec {
|
||||
|
||||
object BlogEntity {
|
||||
|
||||
object BlogState {
|
||||
val empty: BlogState = BlogState(None, LwwTime(Long.MinValue, ReplicaId("")), published = false)
|
||||
}
|
||||
final case class BlogState(content: Option[PostContent], contentTimestamp: LwwTime, published: Boolean)
|
||||
extends CborSerializable {
|
||||
def withContent(newContent: PostContent, timestamp: LwwTime): BlogState =
|
||||
copy(content = Some(newContent), contentTimestamp = timestamp)
|
||||
|
||||
def isEmpty: Boolean = content.isEmpty
|
||||
}
|
||||
|
||||
final case class PostContent(title: String, body: String) extends CborSerializable
|
||||
final case class Published(postId: String) extends Event
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
final case class AddPost(postId: String, content: PostContent, replyTo: ActorRef[AddPostDone]) extends Command
|
||||
final case class AddPostDone(postId: String)
|
||||
final case class GetPost(postId: String, replyTo: ActorRef[PostContent]) extends Command
|
||||
final case class ChangeBody(postId: String, newContent: PostContent, replyTo: ActorRef[Done]) extends Command
|
||||
final case class Publish(postId: String, replyTo: ActorRef[Done]) extends Command
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class PostAdded(postId: String, content: PostContent, timestamp: LwwTime) extends Event
|
||||
final case class BodyChanged(postId: String, newContent: PostContent, timestamp: LwwTime) extends Event
|
||||
|
||||
def apply(entityId: String, replicaId: ReplicaId, allReplicaIds: Set[ReplicaId]): Behavior[Command] = {
|
||||
Behaviors.setup[Command] { ctx =>
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("blog", entityId, replicaId),
|
||||
allReplicaIds,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationContext =>
|
||||
EventSourcedBehavior[Command, Event, BlogState](
|
||||
replicationContext.persistenceId,
|
||||
BlogState.empty,
|
||||
(state, cmd) => commandHandler(ctx, replicationContext, state, cmd),
|
||||
(state, event) => eventHandler(ctx, replicationContext, state, event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #command-handler
|
||||
private def commandHandler(
|
||||
ctx: ActorContext[Command],
|
||||
replicationContext: ReplicationContext,
|
||||
state: BlogState,
|
||||
cmd: Command): Effect[Event, BlogState] = {
|
||||
cmd match {
|
||||
case AddPost(_, content, replyTo) =>
|
||||
val evt =
|
||||
PostAdded(
|
||||
replicationContext.entityId,
|
||||
content,
|
||||
state.contentTimestamp.increase(replicationContext.currentTimeMillis(), replicationContext.replicaId))
|
||||
Effect.persist(evt).thenRun { _ =>
|
||||
replyTo ! AddPostDone(replicationContext.entityId)
|
||||
}
|
||||
case ChangeBody(_, newContent, replyTo) =>
|
||||
val evt =
|
||||
BodyChanged(
|
||||
replicationContext.entityId,
|
||||
newContent,
|
||||
state.contentTimestamp.increase(replicationContext.currentTimeMillis(), replicationContext.replicaId))
|
||||
Effect.persist(evt).thenRun { _ =>
|
||||
replyTo ! Done
|
||||
}
|
||||
case p: Publish =>
|
||||
Effect.persist(Published("id")).thenRun { _ =>
|
||||
p.replyTo ! Done
|
||||
}
|
||||
case gp: GetPost =>
|
||||
ctx.log.info("GetPost {}", state.content)
|
||||
state.content.foreach(content => gp.replyTo ! content)
|
||||
Effect.none
|
||||
}
|
||||
}
|
||||
// #command-handler
|
||||
|
||||
// #event-handler
|
||||
private def eventHandler(
|
||||
ctx: ActorContext[Command],
|
||||
replicationContext: ReplicationContext,
|
||||
state: BlogState,
|
||||
event: Event): BlogState = {
|
||||
ctx.log.info(s"${replicationContext.entityId}:${replicationContext.replicaId} Received event $event")
|
||||
event match {
|
||||
case PostAdded(_, content, timestamp) =>
|
||||
if (timestamp.isAfter(state.contentTimestamp)) {
|
||||
val s = state.withContent(content, timestamp)
|
||||
ctx.log.info("Updating content. New content is {}", s)
|
||||
s
|
||||
} else {
|
||||
ctx.log.info("Ignoring event as timestamp is older")
|
||||
state
|
||||
}
|
||||
case BodyChanged(_, newContent, timestamp) =>
|
||||
if (timestamp.isAfter(state.contentTimestamp))
|
||||
state.withContent(newContent, timestamp)
|
||||
else state
|
||||
case Published(_) =>
|
||||
state.copy(published = true)
|
||||
}
|
||||
}
|
||||
// #event-handler
|
||||
}
|
||||
}
|
||||
|
||||
class ReplicatedBlogExampleSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import ReplicatedBlogExampleSpec.BlogEntity
|
||||
import ReplicatedBlogExampleSpec.BlogEntity._
|
||||
|
||||
"Blog Example" should {
|
||||
"work" in {
|
||||
val refDcA: ActorRef[Command] =
|
||||
spawn(BlogEntity("cat", ReplicaId("DC-A"), Set(ReplicaId("DC-A"), ReplicaId("DC-B"))))
|
||||
|
||||
val refDcB: ActorRef[Command] =
|
||||
spawn(BlogEntity("cat", ReplicaId("DC-B"), Set(ReplicaId("DC-A"), ReplicaId("DC-B"))))
|
||||
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import pekko.actor.typed.scaladsl.AskPattern._
|
||||
import pekko.util.Timeout
|
||||
implicit val timeout: Timeout = 3.seconds
|
||||
|
||||
val content = PostContent("cats are the bets", "yep")
|
||||
val response =
|
||||
refDcA.ask[AddPostDone](replyTo => AddPost("cat", content, replyTo)).futureValue
|
||||
|
||||
response shouldEqual AddPostDone("cat")
|
||||
|
||||
eventually {
|
||||
refDcA.ask[PostContent](replyTo => GetPost("cat", replyTo)).futureValue shouldEqual content
|
||||
}
|
||||
|
||||
eventually {
|
||||
refDcB.ask[PostContent](replyTo => GetPost("cat", replyTo)).futureValue shouldEqual content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package docs.org.apache.pekko.persistence.typed
|
||||
|
||||
import scala.annotation.nowarn
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.typed.ActorSystem
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
|
||||
@nowarn("msg=never used")
|
||||
object ReplicatedEventSourcingCompileOnlySpec {
|
||||
|
||||
// #replicas
|
||||
val DCA = ReplicaId("DC-A")
|
||||
val DCB = ReplicaId("DC-B")
|
||||
val AllReplicas = Set(DCA, DCB)
|
||||
// #replicas
|
||||
|
||||
val queryPluginId = ""
|
||||
|
||||
trait Command
|
||||
trait State
|
||||
trait Event
|
||||
|
||||
object Shared {
|
||||
// #factory-shared
|
||||
def apply(
|
||||
system: ActorSystem[_],
|
||||
entityId: String,
|
||||
replicaId: ReplicaId): EventSourcedBehavior[Command, State, Event] = {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("MyReplicatedEntity", entityId, replicaId),
|
||||
AllReplicas,
|
||||
queryPluginId) { replicationContext =>
|
||||
EventSourcedBehavior[Command, State, Event](???, ???, ???, ???)
|
||||
}
|
||||
}
|
||||
// #factory-shared
|
||||
}
|
||||
|
||||
object PerReplica {
|
||||
// #factory
|
||||
def apply(
|
||||
system: ActorSystem[_],
|
||||
entityId: String,
|
||||
replicaId: ReplicaId): EventSourcedBehavior[Command, State, Event] = {
|
||||
ReplicatedEventSourcing.perReplicaJournalConfig(
|
||||
ReplicationId("MyReplicatedEntity", entityId, replicaId),
|
||||
Map(DCA -> "journalForDCA", DCB -> "journalForDCB")) { replicationContext =>
|
||||
EventSourcedBehavior[Command, State, Event](???, ???, ???, ???)
|
||||
}
|
||||
}
|
||||
|
||||
// #factory
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package docs.org.apache.pekko.persistence.typed
|
||||
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.crdt.ORSet
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
|
||||
object ReplicatedMovieWatchListExampleSpec {
|
||||
// #movie-entity
|
||||
object MovieWatchList {
|
||||
sealed trait Command
|
||||
final case class AddMovie(movieId: String) extends Command
|
||||
final case class RemoveMovie(movieId: String) extends Command
|
||||
final case class GetMovieList(replyTo: ActorRef[MovieList]) extends Command
|
||||
final case class MovieList(movieIds: Set[String])
|
||||
|
||||
def apply(entityId: String, replicaId: ReplicaId, allReplicaIds: Set[ReplicaId]): Behavior[Command] = {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("movies", entityId, replicaId),
|
||||
allReplicaIds,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationContext =>
|
||||
EventSourcedBehavior[Command, ORSet.DeltaOp, ORSet[String]](
|
||||
replicationContext.persistenceId,
|
||||
ORSet.empty(replicationContext.replicaId),
|
||||
(state, cmd) => commandHandler(state, cmd),
|
||||
(state, event) => eventHandler(state, event))
|
||||
}
|
||||
}
|
||||
|
||||
private def commandHandler(state: ORSet[String], cmd: Command): Effect[ORSet.DeltaOp, ORSet[String]] = {
|
||||
cmd match {
|
||||
case AddMovie(movieId) =>
|
||||
Effect.persist(state + movieId)
|
||||
case RemoveMovie(movieId) =>
|
||||
Effect.persist(state - movieId)
|
||||
case GetMovieList(replyTo) =>
|
||||
replyTo ! MovieList(state.elements)
|
||||
Effect.none
|
||||
}
|
||||
}
|
||||
|
||||
private def eventHandler(state: ORSet[String], event: ORSet.DeltaOp): ORSet[String] = {
|
||||
state.applyOperation(event)
|
||||
}
|
||||
|
||||
}
|
||||
// #movie-entity
|
||||
|
||||
}
|
||||
|
||||
class ReplicatedMovieWatchListExampleSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import ReplicatedMovieWatchListExampleSpec._
|
||||
|
||||
"MovieWatchList" must {
|
||||
"demonstrate ORSet" in {
|
||||
import MovieWatchList._
|
||||
|
||||
val Replicas = Set(ReplicaId("DC-A"), ReplicaId("DC-B"))
|
||||
|
||||
val dcAReplica: ActorRef[Command] = spawn(MovieWatchList("mylist", ReplicaId("DC-A"), Replicas))
|
||||
val dcBReplica: ActorRef[Command] = spawn(MovieWatchList("mylist", ReplicaId("DC-B"), Replicas))
|
||||
|
||||
val probeA = createTestProbe[MovieList]()
|
||||
val probeB = createTestProbe[MovieList]()
|
||||
|
||||
dcAReplica ! AddMovie("movie-15")
|
||||
dcAReplica ! AddMovie("movie-17")
|
||||
dcBReplica ! AddMovie("movie-20")
|
||||
|
||||
eventually {
|
||||
dcAReplica ! GetMovieList(probeA.ref)
|
||||
probeA.expectMessage(MovieList(Set("movie-15", "movie-17", "movie-20")))
|
||||
dcBReplica ! GetMovieList(probeB.ref)
|
||||
probeB.expectMessage(MovieList(Set("movie-15", "movie-17", "movie-20")))
|
||||
}
|
||||
|
||||
dcBReplica ! RemoveMovie("movie-17")
|
||||
eventually {
|
||||
dcAReplica ! GetMovieList(probeA.ref)
|
||||
probeA.expectMessage(MovieList(Set("movie-15", "movie-20")))
|
||||
dcBReplica ! GetMovieList(probeB.ref)
|
||||
probeB.expectMessage(MovieList(Set("movie-15", "movie-20")))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package docs.org.apache.pekko.persistence.typed
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
import docs.org.apache.pekko.persistence.typed.ReplicatedShoppingCartExampleSpec.ShoppingCart.CartItems
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.crdt.Counter
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
|
||||
object ReplicatedShoppingCartExampleSpec {
|
||||
|
||||
// #shopping-cart
|
||||
object ShoppingCart {
|
||||
|
||||
type ProductId = String
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
final case class AddItem(id: ProductId, count: Int) extends Command
|
||||
final case class RemoveItem(id: ProductId, count: Int) extends Command
|
||||
final case class GetCartItems(replyTo: ActorRef[CartItems]) extends Command
|
||||
final case class CartItems(items: Map[ProductId, Int]) extends CborSerializable
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class ItemUpdated(id: ProductId, update: Counter.Updated) extends Event
|
||||
|
||||
final case class State(items: Map[ProductId, Counter])
|
||||
|
||||
def apply(entityId: String, replicaId: ReplicaId, allReplicaIds: Set[ReplicaId]): Behavior[Command] = {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("blog", entityId, replicaId),
|
||||
allReplicaIds,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationContext =>
|
||||
EventSourcedBehavior[Command, Event, State](
|
||||
replicationContext.persistenceId,
|
||||
State(Map.empty),
|
||||
(state, cmd) => commandHandler(state, cmd),
|
||||
(state, event) => eventHandler(state, event))
|
||||
}
|
||||
}
|
||||
|
||||
private def commandHandler(state: State, cmd: Command): Effect[Event, State] = {
|
||||
cmd match {
|
||||
case AddItem(productId, count) =>
|
||||
Effect.persist(ItemUpdated(productId, Counter.Updated(count)))
|
||||
case RemoveItem(productId, count) =>
|
||||
Effect.persist(ItemUpdated(productId, Counter.Updated(-count)))
|
||||
case GetCartItems(replyTo) =>
|
||||
val items = state.items.collect {
|
||||
case (id, counter) if counter.value > 0 => id -> counter.value.toInt
|
||||
}
|
||||
replyTo ! CartItems(items)
|
||||
Effect.none
|
||||
}
|
||||
}
|
||||
|
||||
private def eventHandler(state: State, event: Event): State = {
|
||||
event match {
|
||||
case ItemUpdated(id, update) =>
|
||||
val newItems = state.items.get(id) match {
|
||||
case Some(counter) => state.items + (id -> counter.applyOperation(update))
|
||||
case None => state.items + (id -> Counter.empty.applyOperation(update))
|
||||
}
|
||||
State(newItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
// #shopping-cart
|
||||
}
|
||||
|
||||
class ReplicatedShoppingCartExampleSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import ReplicatedShoppingCartExampleSpec.ShoppingCart
|
||||
|
||||
"Replicated shopping cart" should {
|
||||
"work" in {
|
||||
val cartId = UUID.randomUUID().toString
|
||||
|
||||
val refDcA: ActorRef[ShoppingCart.Command] =
|
||||
spawn(ShoppingCart(cartId, ReplicaId("DC-A"), Set(ReplicaId("DC-A"), ReplicaId("DC-B"))))
|
||||
|
||||
val refDcB: ActorRef[ShoppingCart.Command] =
|
||||
spawn(ShoppingCart(cartId, ReplicaId("DC-B"), Set(ReplicaId("DC-A"), ReplicaId("DC-B"))))
|
||||
|
||||
val fidgetSpinnerId = "T2912"
|
||||
val rubicsCubeId = "T1302"
|
||||
|
||||
refDcA ! ShoppingCart.AddItem(fidgetSpinnerId, 10)
|
||||
refDcB ! ShoppingCart.AddItem(rubicsCubeId, 10)
|
||||
refDcA ! ShoppingCart.AddItem(rubicsCubeId, 10)
|
||||
refDcA ! ShoppingCart.AddItem(fidgetSpinnerId, 10)
|
||||
refDcB ! ShoppingCart.AddItem(fidgetSpinnerId, 10)
|
||||
refDcA ! ShoppingCart.RemoveItem(fidgetSpinnerId, 10)
|
||||
refDcA ! ShoppingCart.AddItem(rubicsCubeId, 10)
|
||||
refDcB ! ShoppingCart.RemoveItem(rubicsCubeId, 10)
|
||||
|
||||
val replyProbe = createTestProbe[CartItems]()
|
||||
|
||||
eventually {
|
||||
refDcA ! ShoppingCart.GetCartItems(replyProbe.ref)
|
||||
replyProbe.expectMessage(CartItems(Map(fidgetSpinnerId -> 20, rubicsCubeId -> 20)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.eventstream.EventStream
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object EventPublishingSpec {
|
||||
|
||||
object WowSuchEventSourcingBehavior {
|
||||
sealed trait Command
|
||||
case class StoreThis(data: String, tagIt: Boolean, replyTo: ActorRef[Done]) extends Command
|
||||
|
||||
final case class Event(data: String, tagIt: Boolean) extends CborSerializable
|
||||
|
||||
def apply(id: PersistenceId): Behavior[Command] =
|
||||
EventSourcedBehavior[Command, Event, Set[Event]](
|
||||
id,
|
||||
Set.empty,
|
||||
(_, command) =>
|
||||
command match {
|
||||
case StoreThis(data, tagIt, replyTo) =>
|
||||
Effect.persist(Event(data, tagIt)).thenRun(_ => replyTo ! Done)
|
||||
},
|
||||
(state, event) => state + event)
|
||||
.withTagger(evt => if (evt.tagIt) Set("tag") else Set.empty)
|
||||
.withEventPublishing(enabled = true)
|
||||
}
|
||||
}
|
||||
|
||||
class EventPublishingSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventPublishingSpec._
|
||||
|
||||
"EventPublishing support" must {
|
||||
|
||||
"publish events after written for any actor" in {
|
||||
val topicProbe = createTestProbe[PublishedEvent]()
|
||||
system.eventStream ! EventStream.Subscribe(topicProbe.ref)
|
||||
// We don't verify subscription completed (no ack available), but expect the next steps to take enough time
|
||||
// for subscription to complete
|
||||
|
||||
val myId = PersistenceId.ofUniqueId("myId")
|
||||
val wowSuchActor = spawn(WowSuchEventSourcingBehavior(myId))
|
||||
|
||||
val persistProbe = createTestProbe[Any]()
|
||||
wowSuchActor ! WowSuchEventSourcingBehavior.StoreThis("great stuff", tagIt = false, replyTo = persistProbe.ref)
|
||||
persistProbe.expectMessage(Done)
|
||||
|
||||
val published1 = topicProbe.receiveMessage()
|
||||
published1.persistenceId should ===(myId)
|
||||
published1.event should ===(WowSuchEventSourcingBehavior.Event("great stuff", false))
|
||||
published1.sequenceNumber should ===(1L)
|
||||
published1.tags should ===(Set.empty)
|
||||
|
||||
val anotherId = PersistenceId.ofUniqueId("anotherId")
|
||||
val anotherActor = spawn(WowSuchEventSourcingBehavior(anotherId))
|
||||
anotherActor ! WowSuchEventSourcingBehavior.StoreThis("another event", tagIt = true, replyTo = persistProbe.ref)
|
||||
persistProbe.expectMessage(Done)
|
||||
|
||||
val published2 = topicProbe.receiveMessage()
|
||||
published2.persistenceId should ===(anotherId)
|
||||
published2.event should ===(WowSuchEventSourcingBehavior.Event("another event", true))
|
||||
published2.sequenceNumber should ===(1L)
|
||||
published2.tags should ===(Set("tag"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright (C) 2021-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.LoggingTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.EventSourcedBehaviorLoggingSpec.ChattyEventSourcingBehavior.Hello
|
||||
import pekko.persistence.typed.EventSourcedBehaviorLoggingSpec.ChattyEventSourcingBehavior.Hellos
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import com.typesafe.config.{ Config, ConfigFactory }
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import pekko.Done
|
||||
import pekko.actor.typed.ActorRef
|
||||
|
||||
object EventSourcedBehaviorLoggingSpec {
|
||||
|
||||
object ChattyEventSourcingBehavior {
|
||||
sealed trait Command
|
||||
|
||||
case class Hello(msg: String, replyTo: ActorRef[Done]) extends Command
|
||||
case class Hellos(msg1: String, msg2: String, replyTo: ActorRef[Done]) extends Command
|
||||
|
||||
final case class Event(msg: String) extends CborSerializable
|
||||
|
||||
def apply(id: PersistenceId): Behavior[Command] = {
|
||||
Behaviors.setup { ctx =>
|
||||
EventSourcedBehavior[Command, Event, Set[Event]](
|
||||
id,
|
||||
Set.empty,
|
||||
(_, command) =>
|
||||
command match {
|
||||
case Hello(msg, replyTo) =>
|
||||
ctx.log.info("received message '{}'", msg)
|
||||
Effect.persist(Event(msg)).thenReply(replyTo)(_ => Done)
|
||||
|
||||
case Hellos(msg1, msg2, replyTo) =>
|
||||
Effect.persist(Event(msg1), Event(msg2)).thenReply(replyTo)(_ => Done)
|
||||
},
|
||||
(state, event) => state + event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class EventSourcedBehaviorLoggingSpec(config: Config)
|
||||
extends ScalaTestWithActorTestKit(config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import EventSourcedBehaviorLoggingSpec._
|
||||
|
||||
def loggerName: String
|
||||
def loggerId: String
|
||||
|
||||
s"Chatty behavior ($loggerId)" must {
|
||||
val myId = PersistenceId("Chatty", "chat-1")
|
||||
val chattyActor = spawn(ChattyEventSourcingBehavior(myId))
|
||||
|
||||
"always log user message in context.log" in {
|
||||
val doneProbe = createTestProbe[Done]()
|
||||
LoggingTestKit
|
||||
.info("received message 'Mary'")
|
||||
.withLoggerName(
|
||||
"org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$")
|
||||
.expect {
|
||||
chattyActor ! Hello("Mary", doneProbe.ref)
|
||||
doneProbe.receiveMessage()
|
||||
}
|
||||
}
|
||||
|
||||
s"log internal messages in '$loggerId' logger without logging user data (Persist)" in {
|
||||
val doneProbe = createTestProbe[Done]()
|
||||
LoggingTestKit
|
||||
.debug(
|
||||
"Handled command [org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$Hello], " +
|
||||
"resulting effect: [Persist(org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$Event)], side effects: [1]")
|
||||
.withLoggerName(loggerName)
|
||||
.expect {
|
||||
chattyActor ! Hello("Joe", doneProbe.ref)
|
||||
doneProbe.receiveMessage()
|
||||
}
|
||||
}
|
||||
|
||||
s"log internal messages in '$loggerId' logger without logging user data (PersistAll)" in {
|
||||
val doneProbe = createTestProbe[Done]()
|
||||
LoggingTestKit
|
||||
.debug(
|
||||
"Handled command [org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$Hellos], " +
|
||||
"resulting effect: [PersistAll(org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$Event," +
|
||||
"org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$Event)], side effects: [1]")
|
||||
.withLoggerName(loggerName)
|
||||
.expect {
|
||||
chattyActor ! Hellos("Mary", "Joe", doneProbe.ref)
|
||||
doneProbe.receiveMessage()
|
||||
}
|
||||
}
|
||||
|
||||
s"log in '$loggerId' while preserving MDC source" in {
|
||||
val doneProbe = createTestProbe[Done]()
|
||||
LoggingTestKit
|
||||
.debug("Handled command ")
|
||||
.withLoggerName(loggerName)
|
||||
.withMdc(Map("persistencePhase" -> "running-cmd", "persistenceId" -> "Chatty|chat-1"))
|
||||
.expect {
|
||||
chattyActor ! Hello("Mary", doneProbe.ref)
|
||||
doneProbe.receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorLoggingInternalLoggerSpec
|
||||
extends EventSourcedBehaviorLoggingSpec(PersistenceTestKitPlugin.config) {
|
||||
override def loggerName = "org.apache.pekko.persistence.typed.internal.EventSourcedBehaviorImpl"
|
||||
override def loggerId = "internal.log"
|
||||
}
|
||||
|
||||
object EventSourcedBehaviorLoggingContextLoggerSpec {
|
||||
val config =
|
||||
ConfigFactory
|
||||
.parseString("pekko.persistence.typed.use-context-logger-for-internal-logging = true")
|
||||
.withFallback(PersistenceTestKitPlugin.config)
|
||||
}
|
||||
class EventSourcedBehaviorLoggingContextLoggerSpec
|
||||
extends EventSourcedBehaviorLoggingSpec(EventSourcedBehaviorLoggingContextLoggerSpec.config) {
|
||||
override def loggerName =
|
||||
"org.apache.pekko.persistence.typed.EventSourcedBehaviorLoggingSpec$ChattyEventSourcingBehavior$"
|
||||
override def loggerId = "context.log"
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.query.PersistenceQuery
|
||||
import pekko.persistence.query.scaladsl.CurrentEventsByPersistenceIdQuery
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.stream.scaladsl.Sink
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.concurrent.Eventually
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object MultiJournalReplicationSpec {
|
||||
|
||||
object Actor {
|
||||
sealed trait Command
|
||||
case class GetState(replyTo: ActorRef[Set[String]]) extends Command
|
||||
case class StoreMe(text: String, ack: ActorRef[Done]) extends Command
|
||||
|
||||
private val writeJournalPerReplica = Map("R1" -> "journal1.journal", "R2" -> "journal2.journal")
|
||||
def apply(entityId: String, replicaId: String): Behavior[Command] = {
|
||||
ReplicatedEventSourcing
|
||||
.perReplicaJournalConfig(
|
||||
ReplicationId("MultiJournalSpec", entityId, ReplicaId(replicaId)),
|
||||
Map(ReplicaId("R1") -> "journal1.query", ReplicaId("R2") -> "journal2.query"))(replicationContext =>
|
||||
EventSourcedBehavior[Command, String, Set[String]](
|
||||
replicationContext.persistenceId,
|
||||
Set.empty[String],
|
||||
(state, command) =>
|
||||
command match {
|
||||
case GetState(replyTo) =>
|
||||
replyTo ! state
|
||||
Effect.none
|
||||
case StoreMe(evt, ack) =>
|
||||
Effect.persist(evt).thenRun(_ => ack ! Done)
|
||||
},
|
||||
(state, event) => state + event))
|
||||
.withJournalPluginId(writeJournalPerReplica(replicaId))
|
||||
}
|
||||
}
|
||||
|
||||
def separateJournalsConfig: Config = ConfigFactory.parseString(s"""
|
||||
journal1 {
|
||||
journal.class = "${classOf[PersistenceTestKitPlugin].getName}"
|
||||
query = $${pekko.persistence.testkit.query}
|
||||
}
|
||||
journal2 {
|
||||
journal.class = "${classOf[PersistenceTestKitPlugin].getName}"
|
||||
query = $${pekko.persistence.testkit.query}
|
||||
}
|
||||
""").withFallback(ConfigFactory.load()).resolve()
|
||||
|
||||
}
|
||||
|
||||
class MultiJournalReplicationSpec
|
||||
extends ScalaTestWithActorTestKit(MultiJournalReplicationSpec.separateJournalsConfig)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing
|
||||
with Eventually {
|
||||
import MultiJournalReplicationSpec._
|
||||
val ids = new AtomicInteger(0)
|
||||
def nextEntityId = s"e-${ids.getAndIncrement()}"
|
||||
"ReplicatedEventSourcing" should {
|
||||
"support one journal per replica" in {
|
||||
|
||||
val r1 = spawn(Actor("id1", "R1"))
|
||||
val r2 = spawn(Actor("id1", "R2"))
|
||||
|
||||
val probe = createTestProbe[Any]()
|
||||
r1 ! Actor.StoreMe("r1 m1", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
r2 ! Actor.StoreMe("r2 m1", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
eventually {
|
||||
val probe = createTestProbe[Set[String]]()
|
||||
r1 ! Actor.GetState(probe.ref)
|
||||
probe.receiveMessage() should ===(Set("r1 m1", "r2 m1"))
|
||||
|
||||
r2 ! Actor.GetState(probe.ref)
|
||||
probe.receiveMessage() should ===(Set("r1 m1", "r2 m1"))
|
||||
}
|
||||
|
||||
val readJournal1 = PersistenceQuery(system).readJournalFor[CurrentEventsByPersistenceIdQuery]("journal1.query")
|
||||
val readJournal2 = PersistenceQuery(system).readJournalFor[CurrentEventsByPersistenceIdQuery]("journal2.query")
|
||||
|
||||
val eventsForJournal1 =
|
||||
readJournal1
|
||||
.currentEventsByPersistenceId("MultiJournalSpec|id1|R1", 0L, Long.MaxValue)
|
||||
.runWith(Sink.seq)
|
||||
.futureValue
|
||||
eventsForJournal1.map(_.event).toSet should ===(Set("r1 m1", "r2 m1"))
|
||||
|
||||
val eventsForJournal2 =
|
||||
readJournal2
|
||||
.currentEventsByPersistenceId("MultiJournalSpec|id1|R2", 0L, Long.MaxValue)
|
||||
.runWith(Sink.seq)
|
||||
.futureValue
|
||||
eventsForJournal2.map(_.event).toSet should ===(Set("r1 m1", "r2 m1"))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.internal.{ ReplicatedPublishedEventMetaData, VersionVector }
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicatedEventPublishingSpec {
|
||||
|
||||
val EntityType = "EventPublishingSpec"
|
||||
|
||||
object MyReplicatedBehavior {
|
||||
trait Command
|
||||
case class Add(text: String, replyTo: ActorRef[Done]) extends Command
|
||||
case class Get(replyTo: ActorRef[Set[String]]) extends Command
|
||||
case object Stop extends Command
|
||||
|
||||
def apply(entityId: String, replicaId: ReplicaId, allReplicas: Set[ReplicaId]): Behavior[Command] =
|
||||
Behaviors.setup { ctx =>
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId(EntityType, entityId, replicaId),
|
||||
allReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier)(replicationContext =>
|
||||
EventSourcedBehavior[Command, String, Set[String]](
|
||||
replicationContext.persistenceId,
|
||||
Set.empty,
|
||||
(state, command) =>
|
||||
command match {
|
||||
case Add(string, replyTo) =>
|
||||
ctx.log.debug("Persisting [{}]", string)
|
||||
Effect.persist(string).thenRun { _ =>
|
||||
ctx.log.debug("Ack:ing [{}]", string)
|
||||
replyTo ! Done
|
||||
}
|
||||
case Get(replyTo) =>
|
||||
replyTo ! state
|
||||
Effect.none
|
||||
case Stop =>
|
||||
Effect.stop()
|
||||
case unexpected => throw new RuntimeException(s"Unexpected: $unexpected")
|
||||
},
|
||||
(state, string) => state + string))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReplicatedEventPublishingSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
val DCA = ReplicaId("DC-A")
|
||||
val DCB = ReplicaId("DC-B")
|
||||
val DCC = ReplicaId("DC-C")
|
||||
|
||||
private var idCounter = 0
|
||||
def nextEntityId(): String = {
|
||||
idCounter += 1
|
||||
s"myId$idCounter"
|
||||
}
|
||||
|
||||
import ReplicatedEventPublishingSpec._
|
||||
|
||||
"An Replicated Event Sourced actor" must {
|
||||
"move forward when a published event from a replica is received" in {
|
||||
val id = nextEntityId()
|
||||
val actor = spawn(MyReplicatedBehavior(id, DCA, Set(DCA, DCB)))
|
||||
val probe = createTestProbe[Any]()
|
||||
actor ! MyReplicatedBehavior.Add("one", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// simulate a published event from another replica
|
||||
actor.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCB).persistenceId,
|
||||
1L,
|
||||
"two",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
actor ! MyReplicatedBehavior.Add("three", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
actor ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one", "two", "three"))
|
||||
}
|
||||
|
||||
"ignore a published event from a replica is received but the sequence number is unexpected" in {
|
||||
val id = nextEntityId()
|
||||
val actor = spawn(MyReplicatedBehavior(id, DCA, Set(DCA, DCB)))
|
||||
val probe = createTestProbe[Any]()
|
||||
actor ! MyReplicatedBehavior.Add("one", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// simulate a published event from another replica
|
||||
actor.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCB).persistenceId,
|
||||
2L, // missing 1L
|
||||
"two",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
actor ! MyReplicatedBehavior.Add("three", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
actor ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one", "three"))
|
||||
}
|
||||
|
||||
"ignore a published event from an unknown replica" in {
|
||||
val id = nextEntityId()
|
||||
val actor = spawn(MyReplicatedBehavior(id, DCA, Set(DCA, DCB)))
|
||||
val probe = createTestProbe[Any]()
|
||||
actor ! MyReplicatedBehavior.Add("one", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// simulate a published event from another replica
|
||||
actor.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCC).persistenceId,
|
||||
1L,
|
||||
"two",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCC, VersionVector.empty)))
|
||||
actor ! MyReplicatedBehavior.Add("three", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
actor ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one", "three"))
|
||||
}
|
||||
|
||||
"ignore an already seen event from a replica" in {
|
||||
val id = nextEntityId()
|
||||
val actor = spawn(MyReplicatedBehavior(id, DCA, Set(DCA, DCB)))
|
||||
val probe = createTestProbe[Any]()
|
||||
actor ! MyReplicatedBehavior.Add("one", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// simulate a published event from another replica
|
||||
actor.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, "myId4", DCB).persistenceId,
|
||||
1L,
|
||||
"two",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
// simulate another published event from that replica
|
||||
actor.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCB).persistenceId,
|
||||
1L,
|
||||
"two-again", // ofc this would be the same in the real world, different just so we can detect
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
|
||||
actor ! MyReplicatedBehavior.Add("three", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
actor ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one", "two", "three"))
|
||||
}
|
||||
|
||||
"handle published events after replay" in {
|
||||
val id = nextEntityId()
|
||||
val probe = createTestProbe[Any]()
|
||||
val replicatedBehavior = MyReplicatedBehavior(id, DCA, Set(DCA, DCB))
|
||||
val incarnation1 = spawn(replicatedBehavior)
|
||||
incarnation1 ! MyReplicatedBehavior.Add("one", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
incarnation1 ! MyReplicatedBehavior.Stop
|
||||
probe.expectTerminated(incarnation1)
|
||||
|
||||
val incarnation2 = spawn(replicatedBehavior)
|
||||
|
||||
incarnation2 ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one"))
|
||||
// replay completed
|
||||
|
||||
// simulate a published event from another replica
|
||||
incarnation2.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCB).persistenceId,
|
||||
1L,
|
||||
"two",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
|
||||
incarnation2 ! MyReplicatedBehavior.Add("three", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
incarnation2 ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one", "two", "three"))
|
||||
}
|
||||
|
||||
"handle published events before and after replay" in {
|
||||
val id = nextEntityId()
|
||||
val probe = createTestProbe[Any]()
|
||||
val replicatedBehaviorA = MyReplicatedBehavior(id, DCA, Set(DCA, DCB))
|
||||
val incarnationA1 = spawn(replicatedBehaviorA)
|
||||
incarnationA1 ! MyReplicatedBehavior.Add("one", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// simulate a published event from another replica
|
||||
incarnationA1.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCB).persistenceId,
|
||||
1L,
|
||||
"two",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
|
||||
incarnationA1 ! MyReplicatedBehavior.Stop
|
||||
probe.expectTerminated(incarnationA1)
|
||||
|
||||
val incarnationA2 = spawn(replicatedBehaviorA)
|
||||
|
||||
// simulate a published event from another replica
|
||||
incarnationA2.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, id, DCB).persistenceId,
|
||||
2L,
|
||||
"three",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(DCB, VersionVector.empty)))
|
||||
|
||||
incarnationA2 ! MyReplicatedBehavior.Add("four", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
incarnationA2 ! MyReplicatedBehavior.Get(probe.ref)
|
||||
probe.expectMessage(Set("one", "two", "three", "four"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.testkit.scaladsl.PersistenceTestKit
|
||||
import pekko.persistence.typed.scaladsl.{ Effect, EventSourcedBehavior, ReplicatedEventSourcing, ReplicationContext }
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.concurrent.Eventually
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicatedEventSourcingSpec {
|
||||
|
||||
val AllReplicas = Set(ReplicaId("R1"), ReplicaId("R2"), ReplicaId("R3"))
|
||||
|
||||
sealed trait Command
|
||||
case class GetState(replyTo: ActorRef[State]) extends Command
|
||||
case class StoreMe(description: String, replyTo: ActorRef[Done], latch: CountDownLatch = new CountDownLatch(1))
|
||||
extends Command
|
||||
case class StoreUs(descriptions: List[String], replyTo: ActorRef[Done], latch: CountDownLatch = new CountDownLatch(1))
|
||||
extends Command
|
||||
case class GetReplica(replyTo: ActorRef[(ReplicaId, Set[ReplicaId])]) extends Command
|
||||
case object Stop extends Command
|
||||
|
||||
case class State(all: List[String]) extends CborSerializable
|
||||
|
||||
def testBehavior(entityId: String, replicaId: String, probe: ActorRef[EventAndContext]): Behavior[Command] =
|
||||
testBehavior(entityId, replicaId, Some(probe))
|
||||
|
||||
def eventSourcedBehavior(
|
||||
replicationContext: ReplicationContext,
|
||||
probe: Option[ActorRef[EventAndContext]]): EventSourcedBehavior[Command, String, State] = {
|
||||
EventSourcedBehavior[Command, String, State](
|
||||
replicationContext.persistenceId,
|
||||
State(Nil),
|
||||
(state, command) =>
|
||||
command match {
|
||||
case GetState(replyTo) =>
|
||||
replyTo ! state
|
||||
Effect.none
|
||||
case GetReplica(replyTo) =>
|
||||
replyTo.tell((replicationContext.replicaId, replicationContext.allReplicas))
|
||||
Effect.none
|
||||
case StoreMe(evt, ack, latch) =>
|
||||
latch.countDown()
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
Effect.persist(evt).thenRun(_ => ack ! Done)
|
||||
case StoreUs(evts, replyTo, latch) =>
|
||||
latch.countDown()
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
Effect.persist(evts).thenRun(_ => replyTo ! Done)
|
||||
case Stop =>
|
||||
Effect.stop()
|
||||
},
|
||||
(state, event) => {
|
||||
probe.foreach(
|
||||
_ ! EventAndContext(
|
||||
event,
|
||||
replicationContext.origin,
|
||||
replicationContext.recoveryRunning,
|
||||
replicationContext.concurrent))
|
||||
state.copy(all = event :: state.all)
|
||||
})
|
||||
}
|
||||
|
||||
def testBehavior(
|
||||
entityId: String,
|
||||
replicaId: String,
|
||||
probe: Option[ActorRef[EventAndContext]] = None): Behavior[Command] =
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("ReplicatedEventSourcingSpec", entityId, ReplicaId(replicaId)),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier)(replicationContext => eventSourcedBehavior(replicationContext, probe))
|
||||
|
||||
}
|
||||
|
||||
case class EventAndContext(event: Any, origin: ReplicaId, recoveryRunning: Boolean, concurrent: Boolean)
|
||||
|
||||
class ReplicatedEventSourcingSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing
|
||||
with Eventually {
|
||||
import ReplicatedEventSourcingSpec._
|
||||
val ids = new AtomicInteger(0)
|
||||
def nextEntityId = s"e-${ids.getAndIncrement()}"
|
||||
"ReplicatedEventSourcing" should {
|
||||
"replicate events between two entities" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1"))
|
||||
val r2 = spawn(testBehavior(entityId, "R2"))
|
||||
r1 ! StoreMe("from r1", probe.ref)
|
||||
r2 ! StoreMe("from r2", probe.ref)
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r1 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("from r1", "from r2")
|
||||
}
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r2 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("from r1", "from r2")
|
||||
}
|
||||
}
|
||||
"get all events in recovery" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1"))
|
||||
val r2 = spawn(testBehavior(entityId, "R2"))
|
||||
r1 ! StoreMe("from r1", probe.ref)
|
||||
r2 ! StoreMe("from r2", probe.ref)
|
||||
r1 ! StoreMe("from r1 again", probe.ref)
|
||||
|
||||
val r3 = spawn(testBehavior(entityId, "R3"))
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r3 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("from r1", "from r2", "from r1 again")
|
||||
}
|
||||
}
|
||||
|
||||
"continue after recovery" in {
|
||||
val entityId = nextEntityId
|
||||
val r1Behavior = testBehavior(entityId, "R1")
|
||||
val r2Behavior = testBehavior(entityId, "R2")
|
||||
val probe = createTestProbe[Done]()
|
||||
|
||||
{
|
||||
// first incarnation
|
||||
val r1 = spawn(r1Behavior)
|
||||
val r2 = spawn(r2Behavior)
|
||||
r1 ! StoreMe("1 from r1", probe.ref)
|
||||
r2 ! StoreMe("1 from r2", probe.ref)
|
||||
r1 ! Stop
|
||||
r2 ! Stop
|
||||
probe.expectTerminated(r1)
|
||||
probe.expectTerminated(r2)
|
||||
}
|
||||
|
||||
{
|
||||
// second incarnation
|
||||
val r1 = spawn(r1Behavior)
|
||||
val r2 = spawn(r2Behavior)
|
||||
|
||||
r1 ! StoreMe("2 from r1", probe.ref)
|
||||
r2 ! StoreMe("2 from r2", probe.ref)
|
||||
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r1 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("1 from r1", "1 from r2", "2 from r1", "2 from r2")
|
||||
r2 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("1 from r1", "1 from r2", "2 from r1", "2 from r2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"have access to replica information" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[(ReplicaId, Set[ReplicaId])]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1"))
|
||||
r1 ! GetReplica(probe.ref)
|
||||
probe.expectMessage((ReplicaId("R1"), Set(ReplicaId("R1"), ReplicaId("R2"), ReplicaId("R3"))))
|
||||
}
|
||||
|
||||
"have access to event origin" in {
|
||||
val entityId = nextEntityId
|
||||
val replyProbe = createTestProbe[Done]()
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
val eventProbeR2 = createTestProbe[EventAndContext]()
|
||||
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
val r2 = spawn(testBehavior(entityId, "R2", eventProbeR2.ref))
|
||||
|
||||
r1 ! StoreMe("from r1", replyProbe.ref)
|
||||
eventProbeR2.expectMessage(EventAndContext("from r1", ReplicaId("R1"), false, false))
|
||||
eventProbeR1.expectMessage(EventAndContext("from r1", ReplicaId("R1"), false, false))
|
||||
|
||||
r2 ! StoreMe("from r2", replyProbe.ref)
|
||||
eventProbeR1.expectMessage(EventAndContext("from r2", ReplicaId("R2"), false, false))
|
||||
eventProbeR2.expectMessage(EventAndContext("from r2", ReplicaId("R2"), false, false))
|
||||
}
|
||||
|
||||
"set recovery running" in {
|
||||
val entityId = nextEntityId
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
val replyProbe = createTestProbe[Done]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
r1 ! StoreMe("Event", replyProbe.ref)
|
||||
eventProbeR1.expectMessage(EventAndContext("Event", ReplicaId("R1"), recoveryRunning = false, false))
|
||||
replyProbe.expectMessage(Done)
|
||||
r1 ! Stop
|
||||
replyProbe.expectTerminated(r1)
|
||||
|
||||
val recoveryProbe = createTestProbe[EventAndContext]()
|
||||
spawn(testBehavior(entityId, "R1", recoveryProbe.ref))
|
||||
recoveryProbe.expectMessage(EventAndContext("Event", ReplicaId("R1"), recoveryRunning = true, false))
|
||||
}
|
||||
|
||||
"persist all" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
|
||||
val latch = new CountDownLatch(3)
|
||||
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
val r2 = spawn(testBehavior(entityId, "R2"))
|
||||
r1 ! StoreUs("1 from r1" :: "2 from r1" :: Nil, probe.ref, latch)
|
||||
r2 ! StoreUs("1 from r2" :: "2 from r2" :: Nil, probe.ref, latch)
|
||||
|
||||
// the commands have arrived in both actors, waiting for the latch,
|
||||
// so that the persist of the events will be concurrent
|
||||
latch.countDown()
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
probe.receiveMessage()
|
||||
probe.receiveMessage()
|
||||
|
||||
// events at r2 happened concurrently with events at r1
|
||||
eventProbeR1.expectMessage(EventAndContext("1 from r1", ReplicaId("R1"), false, concurrent = false))
|
||||
eventProbeR1.expectMessage(EventAndContext("2 from r1", ReplicaId("R1"), false, concurrent = false))
|
||||
eventProbeR1.expectMessage(EventAndContext("1 from r2", ReplicaId("R2"), false, concurrent = true))
|
||||
eventProbeR1.expectMessage(EventAndContext("2 from r2", ReplicaId("R2"), false, concurrent = true))
|
||||
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r1 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("1 from r1", "2 from r1", "1 from r2", "2 from r2")
|
||||
}
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r2 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("1 from r1", "2 from r1", "1 from r2", "2 from r2")
|
||||
}
|
||||
}
|
||||
|
||||
"replicate alternate events" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
val eventProbeR2 = createTestProbe[EventAndContext]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
val r2 = spawn(testBehavior(entityId, "R2", eventProbeR2.ref))
|
||||
val latch = new CountDownLatch(3)
|
||||
r1 ! StoreMe("from r1", probe.ref, latch) // R1 0 R2 0 -> R1 1 R2 0
|
||||
r2 ! StoreMe("from r2", probe.ref, latch) // R2 0 R1 0 -> R2 1 R1 0
|
||||
|
||||
// the commands have arrived in both actors, waiting for the latch,
|
||||
// so that the persist of the events will be concurrent
|
||||
latch.countDown()
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
latch.countDown()
|
||||
|
||||
// each gets its local event
|
||||
eventProbeR1.expectMessage(
|
||||
EventAndContext("from r1", ReplicaId("R1"), recoveryRunning = false, concurrent = false))
|
||||
eventProbeR2.expectMessage(
|
||||
EventAndContext("from r2", ReplicaId("R2"), recoveryRunning = false, concurrent = false))
|
||||
|
||||
// then the replicated remote events, which will be concurrent
|
||||
eventProbeR1.expectMessage(
|
||||
EventAndContext("from r2", ReplicaId("R2"), recoveryRunning = false, concurrent = true))
|
||||
eventProbeR2.expectMessage(
|
||||
EventAndContext("from r1", ReplicaId("R1"), recoveryRunning = false, concurrent = true))
|
||||
|
||||
// state is updated
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r1 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("from r1", "from r2")
|
||||
}
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r2 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("from r1", "from r2")
|
||||
}
|
||||
|
||||
// Neither of these should be concurrent, nothing happening at r2
|
||||
r1 ! StoreMe("from r1 2", probe.ref) // R1 1 R2 1
|
||||
eventProbeR1.expectMessage(EventAndContext("from r1 2", ReplicaId("R1"), false, concurrent = false))
|
||||
eventProbeR2.expectMessage(EventAndContext("from r1 2", ReplicaId("R1"), false, concurrent = false))
|
||||
r1 ! StoreMe("from r1 3", probe.ref) // R2 2 R2 1
|
||||
eventProbeR1.expectMessage(EventAndContext("from r1 3", ReplicaId("R1"), false, concurrent = false))
|
||||
eventProbeR2.expectMessage(EventAndContext("from r1 3", ReplicaId("R1"), false, concurrent = false))
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r2 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("from r1", "from r2", "from r1 2", "from r1 3")
|
||||
}
|
||||
|
||||
// not concurrent as the above asserts mean that all events are fully replicated
|
||||
r2 ! StoreMe("from r2 2", probe.ref)
|
||||
eventProbeR1.expectMessage(EventAndContext("from r2 2", ReplicaId("R2"), false, concurrent = false))
|
||||
eventProbeR2.expectMessage(EventAndContext("from r2 2", ReplicaId("R2"), false, concurrent = false))
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
r1 ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set(
|
||||
"from r1",
|
||||
"from r2",
|
||||
"from r1 2",
|
||||
"from r1 3",
|
||||
"from r2 2")
|
||||
}
|
||||
}
|
||||
|
||||
"receive each event only once" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
val eventProbeR2 = createTestProbe[EventAndContext]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
val r2 = spawn(testBehavior(entityId, "R2", eventProbeR2.ref))
|
||||
r1 ! StoreMe("from r1 1", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
r1 ! StoreMe("from r1 2", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// r2, in order because we wrote them both in r1
|
||||
eventProbeR2.expectMessage(EventAndContext("from r1 1", ReplicaId("R1"), false, false))
|
||||
eventProbeR2.expectMessage(EventAndContext("from r1 2", ReplicaId("R1"), false, false))
|
||||
|
||||
r2 ! StoreMe("from r2 1", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
r2 ! StoreMe("from r2 2", probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
// r3 should only get the events 1, not R2s stored version of them, but we don't know the
|
||||
// order they will arrive
|
||||
val eventProbeR3 = createTestProbe[EventAndContext]()
|
||||
spawn(testBehavior(entityId, "R3", eventProbeR3.ref))
|
||||
val eventAndContexts = eventProbeR3.receiveMessages(4).toSet
|
||||
eventAndContexts should ===(
|
||||
Set(
|
||||
EventAndContext("from r1 1", ReplicaId("R1"), false, false),
|
||||
EventAndContext("from r1 2", ReplicaId("R1"), false, false),
|
||||
EventAndContext("from r2 1", ReplicaId("R2"), false, false),
|
||||
EventAndContext("from r2 2", ReplicaId("R2"), false, false)))
|
||||
eventProbeR3.expectNoMessage()
|
||||
}
|
||||
|
||||
"set concurrent on replay of events" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
val r2 = spawn(testBehavior(entityId, "R2"))
|
||||
val latch = new CountDownLatch(3)
|
||||
r1 ! StoreMe("from r1", probe.ref, latch) // R1 0 R2 0 -> R1 1 R2 0
|
||||
r2 ! StoreMe("from r2", probe.ref, latch) // R2 0 R1 0 -> R2 1 R1 0
|
||||
|
||||
// the commands have arrived in both actors, waiting for the latch,
|
||||
// so that the persist of the events will be concurrent
|
||||
latch.countDown()
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
|
||||
// local event isn't concurrent, remote event is
|
||||
eventProbeR1.expectMessage(
|
||||
EventAndContext("from r1", ReplicaId("R1"), recoveryRunning = false, concurrent = false))
|
||||
eventProbeR1.expectMessage(
|
||||
EventAndContext("from r2", ReplicaId("R2"), recoveryRunning = false, concurrent = true))
|
||||
|
||||
r1 ! Stop
|
||||
r2 ! Stop
|
||||
probe.expectTerminated(r1)
|
||||
probe.expectTerminated(r2)
|
||||
|
||||
// take 2
|
||||
val eventProbeR1Take2 = createTestProbe[EventAndContext]()
|
||||
spawn(testBehavior(entityId, "R1", eventProbeR1Take2.ref))
|
||||
eventProbeR1Take2.expectMessage(
|
||||
EventAndContext("from r1", ReplicaId("R1"), recoveryRunning = true, concurrent = false))
|
||||
eventProbeR1Take2.expectMessage(
|
||||
EventAndContext("from r2", ReplicaId("R2"), recoveryRunning = true, concurrent = true))
|
||||
}
|
||||
|
||||
"replicate events between three entities" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
var r1 = spawn(testBehavior(entityId, "R1"))
|
||||
var r2 = spawn(testBehavior(entityId, "R2"))
|
||||
var r3 = spawn(testBehavior(entityId, "R3"))
|
||||
r1 ! StoreMe("1 from r1", probe.ref)
|
||||
r2 ! StoreMe("1 from r2", probe.ref)
|
||||
r3 ! StoreMe("1 from r3", probe.ref)
|
||||
probe.receiveMessages(3) // all writes acked
|
||||
|
||||
(r1 :: r2 :: r3 :: Nil).foreach { replica =>
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
replica ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set("1 from r1", "1 from r2", "1 from r3")
|
||||
replica ! Stop
|
||||
probe.expectTerminated(replica)
|
||||
}
|
||||
}
|
||||
|
||||
// with all replicas stopped, start and write a bit to one of them
|
||||
r1 = spawn(testBehavior(entityId, "R1"))
|
||||
r1 ! StoreMe("2 from r1", probe.ref)
|
||||
r1 ! StoreMe("3 from r1", probe.ref)
|
||||
probe.receiveMessages(2) // both writes acked
|
||||
r1 ! Stop
|
||||
probe.expectTerminated(r1)
|
||||
|
||||
// start the other two
|
||||
r1 = spawn(testBehavior(entityId, "R1"))
|
||||
r2 = spawn(testBehavior(entityId, "R2"))
|
||||
r3 = spawn(testBehavior(entityId, "R3"))
|
||||
|
||||
(r1 :: r2 :: r3 :: Nil).foreach { replica =>
|
||||
eventually {
|
||||
val probe = createTestProbe[State]()
|
||||
replica ! GetState(probe.ref)
|
||||
probe.expectMessageType[State].all.toSet shouldEqual Set(
|
||||
"1 from r1",
|
||||
"2 from r1",
|
||||
"3 from r1",
|
||||
"1 from r2",
|
||||
"1 from r3")
|
||||
replica ! Stop
|
||||
probe.expectTerminated(replica)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
"restart replication stream" in {
|
||||
val testkit = PersistenceTestKit(system)
|
||||
val entityId = nextEntityId
|
||||
val stateProbe = createTestProbe[State]()
|
||||
val probe = createTestProbe[Done]()
|
||||
val eventProbeR1 = createTestProbe[EventAndContext]()
|
||||
val r1 = spawn(testBehavior(entityId, "R1", eventProbeR1.ref))
|
||||
val r2 = spawn(testBehavior(entityId, "R2"))
|
||||
|
||||
// ensure recovery is complete
|
||||
r1 ! GetState(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(Nil))
|
||||
r2 ! GetState(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(Nil))
|
||||
|
||||
// make reads fail for the replication
|
||||
testkit.failNextNReads(s"$entityId|R2", 1)
|
||||
|
||||
// should restart the replication stream
|
||||
r2 ! StoreMe("from r2", probe.ref)
|
||||
eventProbeR1.expectMessageType[EventAndContext].event shouldEqual "from r2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.persistence.query.NoOffset
|
||||
import pekko.persistence.query.scaladsl.CurrentEventsByTagQuery
|
||||
import pekko.persistence.query.PersistenceQuery
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import pekko.stream.scaladsl.Sink
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.concurrent.Eventually
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicatedEventSourcingTaggingSpec {
|
||||
|
||||
val ReplicaId1 = ReplicaId("R1")
|
||||
val ReplicaId2 = ReplicaId("R2")
|
||||
val AllReplicas = Set(ReplicaId1, ReplicaId2)
|
||||
val queryPluginId = PersistenceTestKitReadJournal.Identifier
|
||||
|
||||
object ReplicatedStringSet {
|
||||
|
||||
sealed trait Command
|
||||
case class Add(description: String, replyTo: ActorRef[Done]) extends Command
|
||||
case class GetStrings(replyTo: ActorRef[Set[String]]) extends Command
|
||||
case class State(strings: Set[String]) extends CborSerializable
|
||||
|
||||
def apply(
|
||||
entityId: String,
|
||||
replica: ReplicaId,
|
||||
allReplicas: Set[ReplicaId]): EventSourcedBehavior[Command, String, State] = {
|
||||
// #tagging
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("TaggingSpec", entityId, replica),
|
||||
allReplicas,
|
||||
queryPluginId)(replicationContext =>
|
||||
EventSourcedBehavior[Command, String, State](
|
||||
replicationContext.persistenceId,
|
||||
State(Set.empty),
|
||||
(state, command) =>
|
||||
command match {
|
||||
case Add(string, ack) =>
|
||||
if (state.strings.contains(string)) Effect.none.thenRun(_ => ack ! Done)
|
||||
else Effect.persist(string).thenRun(_ => ack ! Done)
|
||||
case GetStrings(replyTo) =>
|
||||
replyTo ! state.strings
|
||||
Effect.none
|
||||
},
|
||||
(state, event) => state.copy(strings = state.strings + event))
|
||||
// use withTagger to define tagging logic
|
||||
.withTagger(event =>
|
||||
// don't apply tags if event was replicated here, it already will appear in queries by tag
|
||||
// as the origin replica would have tagged it already
|
||||
if (replicationContext.origin != replicationContext.replicaId) Set.empty
|
||||
else if (event.length > 10) Set("long-strings", "strings")
|
||||
else Set("strings")))
|
||||
// #tagging
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ReplicatedEventSourcingTaggingSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing
|
||||
with Eventually {
|
||||
import ReplicatedEventSourcingTaggingSpec._
|
||||
val ids = new AtomicInteger(0)
|
||||
def nextEntityId = s"e-${ids.getAndIncrement()}"
|
||||
"ReplicatedEventSourcing" should {
|
||||
"allow for tagging of events using the replication context" in {
|
||||
val entityId = nextEntityId
|
||||
val probe = createTestProbe[Done]()
|
||||
val r1 = spawn(ReplicatedStringSet(entityId, ReplicaId1, AllReplicas))
|
||||
val r2 = spawn(ReplicatedStringSet(entityId, ReplicaId2, AllReplicas))
|
||||
r1 ! ReplicatedStringSet.Add("from r1", probe.ref)
|
||||
r2 ! ReplicatedStringSet.Add("from r2", probe.ref)
|
||||
probe.receiveMessages(2)
|
||||
r1 ! ReplicatedStringSet.Add("a very long string from r1", probe.ref)
|
||||
probe.receiveMessages(1)
|
||||
|
||||
val allEvents = Set("from r1", "from r2", "a very long string from r1")
|
||||
for (replica <- r1 :: r2 :: Nil) {
|
||||
eventually {
|
||||
val probe = testKit.createTestProbe[Set[String]]()
|
||||
replica ! ReplicatedStringSet.GetStrings(probe.ref)
|
||||
probe.receiveMessage() should ===(allEvents)
|
||||
}
|
||||
}
|
||||
|
||||
val query =
|
||||
PersistenceQuery(system).readJournalFor[CurrentEventsByTagQuery](PersistenceTestKitReadJournal.Identifier)
|
||||
|
||||
val stringTaggedEvents = query.currentEventsByTag("strings", NoOffset).runWith(Sink.seq).futureValue
|
||||
stringTaggedEvents.map(_.event).toSet should equal(allEvents)
|
||||
|
||||
val longStrings = query.currentEventsByTag("long-strings", NoOffset).runWith(Sink.seq).futureValue
|
||||
longStrings should have size 1
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.{ LogCapturing, ScalaTestWithActorTestKit }
|
||||
import pekko.persistence.testkit.{ PersistenceTestKitPlugin, PersistenceTestKitSnapshotPlugin }
|
||||
import org.scalatest.concurrent.Eventually
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicationBaseSpec {
|
||||
val R1 = ReplicaId("R1")
|
||||
val R2 = ReplicaId("R2")
|
||||
val AllReplicas = Set(R1, R2)
|
||||
}
|
||||
|
||||
abstract class ReplicationBaseSpec
|
||||
extends ScalaTestWithActorTestKit(
|
||||
PersistenceTestKitPlugin.config.withFallback(PersistenceTestKitSnapshotPlugin.config))
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing
|
||||
with Eventually {
|
||||
|
||||
val ids = new AtomicInteger(0)
|
||||
def nextEntityId: String = s"e-${ids.getAndIncrement()}"
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.{ LogCapturing, ScalaTestWithActorTestKit }
|
||||
import pekko.actor.typed.{ ActorRef, Behavior }
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.scaladsl.{ Effect, EventSourcedBehavior, ReplicatedEventSourcing }
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.concurrent.Eventually
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicationIllegalAccessSpec {
|
||||
|
||||
val R1 = ReplicaId("R1")
|
||||
val R2 = ReplicaId("R1")
|
||||
val AllReplicas = Set(R1, R2)
|
||||
|
||||
sealed trait Command
|
||||
case class AccessInCommandHandler(replyTo: ActorRef[Thrown]) extends Command
|
||||
case class AccessInPersistCallback(replyTo: ActorRef[Thrown]) extends Command
|
||||
|
||||
case class Thrown(exception: Option[Throwable])
|
||||
|
||||
case class State(all: List[String]) extends CborSerializable
|
||||
|
||||
def apply(entityId: String, replica: ReplicaId): Behavior[Command] = {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("IllegalAccessSpec", entityId, replica),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier)(replicationContext =>
|
||||
EventSourcedBehavior[Command, String, State](
|
||||
replicationContext.persistenceId,
|
||||
State(Nil),
|
||||
(_, command) =>
|
||||
command match {
|
||||
case AccessInCommandHandler(replyTo) =>
|
||||
val exception =
|
||||
try {
|
||||
replicationContext.origin
|
||||
None
|
||||
} catch {
|
||||
case t: Throwable =>
|
||||
Some(t)
|
||||
}
|
||||
replyTo ! Thrown(exception)
|
||||
Effect.none
|
||||
case AccessInPersistCallback(replyTo) =>
|
||||
Effect.persist("cat").thenRun { _ =>
|
||||
val exception =
|
||||
try {
|
||||
replicationContext.concurrent
|
||||
None
|
||||
} catch {
|
||||
case t: Throwable =>
|
||||
Some(t)
|
||||
}
|
||||
replyTo ! Thrown(exception)
|
||||
}
|
||||
},
|
||||
(state, event) => state.copy(all = event :: state.all)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ReplicationIllegalAccessSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing
|
||||
with Eventually {
|
||||
import ReplicationIllegalAccessSpec._
|
||||
"ReplicatedEventSourcing" should {
|
||||
"detect illegal access to context in command handler" in {
|
||||
val probe = createTestProbe[Thrown]()
|
||||
val ref = spawn(ReplicationIllegalAccessSpec("id1", R1))
|
||||
ref ! AccessInCommandHandler(probe.ref)
|
||||
val thrown: Throwable = probe.expectMessageType[Thrown].exception.get
|
||||
thrown.getMessage should include("from the event handler")
|
||||
}
|
||||
"detect illegal access to context in persist thenRun" in {
|
||||
val probe = createTestProbe[Thrown]()
|
||||
val ref = spawn(ReplicationIllegalAccessSpec("id1", R1))
|
||||
ref ! AccessInPersistCallback(probe.ref)
|
||||
val thrown: Throwable = probe.expectMessageType[Thrown].exception.get
|
||||
thrown.getMessage should include("from the event handler")
|
||||
}
|
||||
"detect illegal access in the factory" in {
|
||||
val exception = intercept[UnsupportedOperationException] {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("IllegalAccessSpec", "id2", R1),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationContext =>
|
||||
replicationContext.origin
|
||||
???
|
||||
}
|
||||
}
|
||||
exception.getMessage should include("from the event handler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.{ LogCapturing, ScalaTestWithActorTestKit }
|
||||
import pekko.actor.typed.{ ActorRef, Behavior }
|
||||
import pekko.persistence.testkit.{ PersistenceTestKitPlugin, PersistenceTestKitSnapshotPlugin }
|
||||
import pekko.persistence.testkit.scaladsl.{ PersistenceTestKit, SnapshotTestKit }
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.internal.{ ReplicatedPublishedEventMetaData, VersionVector }
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import org.scalatest.concurrent.Eventually
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicationSnapshotSpec {
|
||||
|
||||
import ReplicatedEventSourcingSpec._
|
||||
|
||||
val EntityType = "SnapshotSpec"
|
||||
|
||||
def behaviorWithSnapshotting(entityId: String, replicaId: ReplicaId): Behavior[Command] =
|
||||
behaviorWithSnapshotting(entityId, replicaId, None)
|
||||
|
||||
def behaviorWithSnapshotting(
|
||||
entityId: String,
|
||||
replicaId: ReplicaId,
|
||||
eventProbe: ActorRef[EventAndContext]): Behavior[Command] =
|
||||
behaviorWithSnapshotting(entityId, replicaId, Some(eventProbe))
|
||||
|
||||
def behaviorWithSnapshotting(
|
||||
entityId: String,
|
||||
replicaId: ReplicaId,
|
||||
probe: Option[ActorRef[EventAndContext]]): Behavior[Command] = {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId(EntityType, entityId, replicaId),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier)(replicationContext =>
|
||||
eventSourcedBehavior(replicationContext, probe).snapshotWhen((_, _, sequenceNr) => sequenceNr % 2 == 0))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class ReplicationSnapshotSpec
|
||||
extends ScalaTestWithActorTestKit(
|
||||
PersistenceTestKitPlugin.config.withFallback(PersistenceTestKitSnapshotPlugin.config))
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing
|
||||
with Eventually {
|
||||
import ReplicatedEventSourcingSpec._
|
||||
import ReplicationSnapshotSpec._
|
||||
|
||||
val ids = new AtomicInteger(0)
|
||||
def nextEntityId = s"e-${ids.getAndIncrement()}"
|
||||
|
||||
val snapshotTestKit = SnapshotTestKit(system)
|
||||
val persistenceTestKit = PersistenceTestKit(system)
|
||||
|
||||
val R1 = ReplicaId("R1")
|
||||
val R2 = ReplicaId("R2")
|
||||
|
||||
"ReplicatedEventSourcing" should {
|
||||
"recover state from snapshots" in {
|
||||
val entityId = nextEntityId
|
||||
val persistenceIdR1 = s"$EntityType|$entityId|R1"
|
||||
val persistenceIdR2 = s"$EntityType|$entityId|R2"
|
||||
val probe = createTestProbe[Done]()
|
||||
val r2EventProbe = createTestProbe[EventAndContext]()
|
||||
|
||||
{
|
||||
val r1 = spawn(behaviorWithSnapshotting(entityId, R1))
|
||||
val r2 = spawn(behaviorWithSnapshotting(entityId, R2, r2EventProbe.ref))
|
||||
r1 ! StoreMe("r1 1", probe.ref)
|
||||
r1 ! StoreMe("r1 2", probe.ref)
|
||||
r2EventProbe.expectMessageType[EventAndContext]
|
||||
r2EventProbe.expectMessageType[EventAndContext]
|
||||
|
||||
snapshotTestKit.expectNextPersisted(persistenceIdR1, State(List("r1 2", "r1 1")))
|
||||
snapshotTestKit.expectNextPersisted(persistenceIdR2, State(List("r1 2", "r1 1")))
|
||||
|
||||
r2.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, entityId, R1).persistenceId,
|
||||
1L,
|
||||
"two-again",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(R1, VersionVector.empty)))
|
||||
|
||||
// r2 should now filter out that event if it receives it again
|
||||
r2EventProbe.expectNoMessage()
|
||||
}
|
||||
|
||||
// restart r2 from a snapshot, the event should still be filtered
|
||||
{
|
||||
val r2 = spawn(behaviorWithSnapshotting(entityId, R2, r2EventProbe.ref))
|
||||
r2.asInstanceOf[ActorRef[Any]] ! internal.PublishedEventImpl(
|
||||
ReplicationId(EntityType, entityId, R1).persistenceId,
|
||||
1L,
|
||||
"two-again",
|
||||
System.currentTimeMillis(),
|
||||
Some(new ReplicatedPublishedEventMetaData(R1, VersionVector.empty)))
|
||||
r2EventProbe.expectNoMessage()
|
||||
|
||||
val stateProbe = createTestProbe[State]()
|
||||
r2 ! GetState(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(List("r1 2", "r1 1")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.crdt
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.crdt.CounterSpec.PlainCounter.{ Decrement, Get, Increment }
|
||||
import pekko.persistence.typed.scaladsl.{ Effect, EventSourcedBehavior, ReplicatedEventSourcing }
|
||||
import pekko.persistence.typed.{ ReplicaId, ReplicationBaseSpec }
|
||||
|
||||
object CounterSpec {
|
||||
|
||||
object PlainCounter {
|
||||
sealed trait Command
|
||||
case class Get(reply: ActorRef[Long]) extends Command
|
||||
case object Increment extends Command
|
||||
case object Decrement extends Command
|
||||
}
|
||||
|
||||
import ReplicationBaseSpec._
|
||||
|
||||
def apply(
|
||||
entityId: String,
|
||||
replicaId: ReplicaId,
|
||||
snapshotEvery: Long = 100,
|
||||
eventProbe: Option[ActorRef[Counter.Updated]] = None) =
|
||||
Behaviors.setup[PlainCounter.Command] { context =>
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("CounterSpec", entityId, replicaId),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier) { ctx =>
|
||||
EventSourcedBehavior[PlainCounter.Command, Counter.Updated, Counter](
|
||||
ctx.persistenceId,
|
||||
Counter.empty,
|
||||
(state, command) =>
|
||||
command match {
|
||||
case PlainCounter.Increment =>
|
||||
context.log.info("Increment. Current state {}", state.value)
|
||||
Effect.persist(Counter.Updated(1))
|
||||
case PlainCounter.Decrement =>
|
||||
Effect.persist(Counter.Updated(-1))
|
||||
case Get(replyTo) =>
|
||||
context.log.info("Get request. {} {}", state.value, state.value.longValue)
|
||||
replyTo ! state.value.longValue
|
||||
Effect.none
|
||||
},
|
||||
(counter, event) => {
|
||||
eventProbe.foreach(_ ! event)
|
||||
counter.applyOperation(event)
|
||||
}).snapshotWhen { (_, _, seqNr) =>
|
||||
seqNr % snapshotEvery == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CounterSpec extends ReplicationBaseSpec {
|
||||
|
||||
import CounterSpec._
|
||||
import ReplicationBaseSpec._
|
||||
|
||||
"Replicated entity using CRDT counter" should {
|
||||
"replicate" in {
|
||||
val id = nextEntityId
|
||||
val r1 = spawn(apply(id, R1))
|
||||
val r2 = spawn(apply(id, R2))
|
||||
val r1Probe = createTestProbe[Long]()
|
||||
val r2Probe = createTestProbe[Long]()
|
||||
|
||||
r1 ! Increment
|
||||
r1 ! Increment
|
||||
|
||||
eventually {
|
||||
r1 ! Get(r1Probe.ref)
|
||||
r1Probe.expectMessage(2L)
|
||||
r2 ! Get(r2Probe.ref)
|
||||
r2Probe.expectMessage(2L)
|
||||
}
|
||||
|
||||
for (n <- 1 to 10) {
|
||||
if (n % 2 == 0) r1 ! Increment
|
||||
else r1 ! Decrement
|
||||
}
|
||||
for (_ <- 1 to 10) {
|
||||
r2 ! Increment
|
||||
}
|
||||
|
||||
eventually {
|
||||
r1 ! Get(r1Probe.ref)
|
||||
r1Probe.expectMessage(12L)
|
||||
r2 ! Get(r2Probe.ref)
|
||||
r2Probe.expectMessage(12L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"recover from snapshot" in {
|
||||
val id = nextEntityId
|
||||
|
||||
{
|
||||
val r1 = spawn(apply(id, R1, 2))
|
||||
val r2 = spawn(apply(id, R2, 2))
|
||||
val r1Probe = createTestProbe[Long]()
|
||||
val r2Probe = createTestProbe[Long]()
|
||||
|
||||
r1 ! Increment
|
||||
r1 ! Increment
|
||||
|
||||
eventually {
|
||||
r1 ! Get(r1Probe.ref)
|
||||
r1Probe.expectMessage(2L)
|
||||
r2 ! Get(r2Probe.ref)
|
||||
r2Probe.expectMessage(2L)
|
||||
}
|
||||
}
|
||||
{
|
||||
val r2EventProbe = createTestProbe[Counter.Updated]()
|
||||
val r2 = spawn(apply(id, R2, 2, Some(r2EventProbe.ref)))
|
||||
val r2Probe = createTestProbe[Long]()
|
||||
eventually {
|
||||
r2 ! Get(r2Probe.ref)
|
||||
r2Probe.expectMessage(2L)
|
||||
}
|
||||
|
||||
r2EventProbe.expectNoMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.crdt
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.scaladsl.Effect
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior
|
||||
import pekko.persistence.typed.scaladsl.ReplicatedEventSourcing
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.ReplicationBaseSpec
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object LwwSpec {
|
||||
|
||||
import ReplicationBaseSpec._
|
||||
|
||||
sealed trait Command
|
||||
final case class Update(item: String, timestamp: Long, error: ActorRef[String], latch: Option[CountDownLatch])
|
||||
extends Command
|
||||
final case class Get(replyTo: ActorRef[Registry]) extends Command
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class Changed(item: String, timestamp: LwwTime) extends Event
|
||||
|
||||
final case class Registry(item: String, updatedTimestamp: LwwTime) extends CborSerializable
|
||||
|
||||
object LwwRegistry {
|
||||
|
||||
def apply(entityId: String, replica: ReplicaId): Behavior[Command] = {
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("LwwRegistrySpec", entityId, replica),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationContext =>
|
||||
EventSourcedBehavior[Command, Event, Registry](
|
||||
replicationContext.persistenceId,
|
||||
Registry("", LwwTime(Long.MinValue, replicationContext.replicaId)),
|
||||
(state, command) =>
|
||||
command match {
|
||||
case Update(s, timestmap, error, maybeLatch) =>
|
||||
if (s == "") {
|
||||
error ! "bad value"
|
||||
Effect.none
|
||||
} else {
|
||||
maybeLatch.foreach { l =>
|
||||
l.countDown()
|
||||
l.await(10, TimeUnit.SECONDS)
|
||||
}
|
||||
Effect.persist(Changed(s, state.updatedTimestamp.increase(timestmap, replicationContext.replicaId)))
|
||||
}
|
||||
case Get(replyTo) =>
|
||||
replyTo ! state
|
||||
Effect.none
|
||||
},
|
||||
(state, event) =>
|
||||
event match {
|
||||
case Changed(s, timestamp) =>
|
||||
if (timestamp.isAfter(state.updatedTimestamp)) Registry(s, timestamp)
|
||||
else state
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class LwwSpec extends ReplicationBaseSpec {
|
||||
import LwwSpec._
|
||||
import ReplicationBaseSpec._
|
||||
|
||||
class Setup {
|
||||
val entityId = nextEntityId
|
||||
val r1 = spawn(LwwRegistry.apply(entityId, R1))
|
||||
val r2 = spawn(LwwRegistry.apply(entityId, R2))
|
||||
val r1Probe = createTestProbe[String]()
|
||||
val r2Probe = createTestProbe[String]()
|
||||
val r1GetProbe = createTestProbe[Registry]()
|
||||
val r2GetProbe = createTestProbe[Registry]()
|
||||
}
|
||||
|
||||
"Lww Replicated Event Sourced Behavior" should {
|
||||
"replicate a single event" in new Setup {
|
||||
r1 ! Update("a1", 1L, r1Probe.ref, None)
|
||||
eventually {
|
||||
val probe = createTestProbe[Registry]()
|
||||
r2 ! Get(probe.ref)
|
||||
probe.expectMessage(Registry("a1", LwwTime(1L, R1)))
|
||||
}
|
||||
}
|
||||
|
||||
"resolve conflict" in new Setup {
|
||||
r1 ! Update("a1", 1L, r1Probe.ref, None)
|
||||
r2 ! Update("b1", 2L, r2Probe.ref, None)
|
||||
eventually {
|
||||
r1 ! Get(r1GetProbe.ref)
|
||||
r2 ! Get(r2GetProbe.ref)
|
||||
r1GetProbe.expectMessage(Registry("b1", LwwTime(2L, R2)))
|
||||
r2GetProbe.expectMessage(Registry("b1", LwwTime(2L, R2)))
|
||||
}
|
||||
}
|
||||
|
||||
"have deterministic tiebreak when the same time" in new Setup {
|
||||
val latch = new CountDownLatch(3)
|
||||
r1 ! Update("a1", 1L, r1Probe.ref, Some(latch))
|
||||
r2 ! Update("b1", 1L, r2Probe.ref, Some(latch))
|
||||
|
||||
// the commands have arrived in both actors, waiting for the latch,
|
||||
// so that the persist of the events will be concurrent
|
||||
latch.countDown()
|
||||
latch.await(10, TimeUnit.SECONDS)
|
||||
|
||||
// R1 < R2
|
||||
eventually {
|
||||
r1 ! Get(r1GetProbe.ref)
|
||||
r2 ! Get(r2GetProbe.ref)
|
||||
r1GetProbe.expectMessage(Registry("a1", LwwTime(1L, R1)))
|
||||
r2GetProbe.expectMessage(Registry("a1", LwwTime(1L, R1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.crdt
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.typed.{ ActorRef, Behavior }
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.scaladsl.{ Effect, EventSourcedBehavior, ReplicatedEventSourcing }
|
||||
import pekko.persistence.typed.{ ReplicaId, ReplicationBaseSpec }
|
||||
import ORSetSpec.ORSetEntity._
|
||||
import pekko.persistence.typed.ReplicationBaseSpec.{ R1, R2 }
|
||||
import pekko.persistence.typed.ReplicationId
|
||||
import pekko.persistence.typed.crdt.ORSetSpec.ORSetEntity
|
||||
|
||||
import scala.util.Random
|
||||
|
||||
object ORSetSpec {
|
||||
|
||||
import ReplicationBaseSpec._
|
||||
|
||||
object ORSetEntity {
|
||||
sealed trait Command
|
||||
final case class Get(replyTo: ActorRef[Set[String]]) extends Command
|
||||
final case class Add(elem: String) extends Command
|
||||
final case class AddAll(elems: Set[String]) extends Command
|
||||
final case class Remove(elem: String) extends Command
|
||||
|
||||
def apply(entityId: String, replica: ReplicaId): Behavior[ORSetEntity.Command] = {
|
||||
|
||||
ReplicatedEventSourcing.commonJournalConfig(
|
||||
ReplicationId("ORSetSpec", entityId, replica),
|
||||
AllReplicas,
|
||||
PersistenceTestKitReadJournal.Identifier) { replicationContext =>
|
||||
EventSourcedBehavior[Command, ORSet.DeltaOp, ORSet[String]](
|
||||
replicationContext.persistenceId,
|
||||
ORSet(replica),
|
||||
(state, command) =>
|
||||
command match {
|
||||
case Add(elem) =>
|
||||
Effect.persist(state + elem)
|
||||
case AddAll(elems) =>
|
||||
Effect.persist(state.addAll(elems.toSet))
|
||||
case Remove(elem) =>
|
||||
Effect.persist(state - elem)
|
||||
case Get(replyTo) =>
|
||||
Effect.none.thenRun(state => replyTo ! state.elements)
|
||||
|
||||
},
|
||||
(state, operation) => state.applyOperation(operation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ORSetSpec extends ReplicationBaseSpec {
|
||||
|
||||
class Setup {
|
||||
val entityId = nextEntityId
|
||||
val r1 = spawn(ORSetEntity.apply(entityId, R1))
|
||||
val r2 = spawn(ORSetEntity.apply(entityId, R2))
|
||||
val r1GetProbe = createTestProbe[Set[String]]()
|
||||
val r2GetProbe = createTestProbe[Set[String]]()
|
||||
|
||||
def assertForAllReplicas(state: Set[String]): Unit = {
|
||||
eventually {
|
||||
r1 ! Get(r1GetProbe.ref)
|
||||
r1GetProbe.expectMessage(state)
|
||||
r2 ! Get(r2GetProbe.ref)
|
||||
r2GetProbe.expectMessage(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def randomDelay(): Unit = {
|
||||
// exercise different timing scenarios
|
||||
Thread.sleep(Random.nextInt(200).toLong)
|
||||
}
|
||||
|
||||
"ORSet Replicated Entity" should {
|
||||
|
||||
"support concurrent updates" in new Setup {
|
||||
r1 ! Add("a1")
|
||||
r2 ! Add("b1")
|
||||
assertForAllReplicas(Set("a1", "b1"))
|
||||
r2 ! Remove("b1")
|
||||
assertForAllReplicas(Set("a1"))
|
||||
r2 ! Add("b1")
|
||||
for (n <- 2 to 10) {
|
||||
r1 ! Add(s"a$n")
|
||||
if (n % 3 == 0)
|
||||
randomDelay()
|
||||
r2 ! Add(s"b$n")
|
||||
}
|
||||
r1 ! AddAll((11 to 13).map(n => s"a$n").toSet)
|
||||
r2 ! AddAll((11 to 13).map(n => s"b$n").toSet)
|
||||
val expected = (1 to 13).flatMap(n => List(s"a$n", s"b$n")).toSet
|
||||
assertForAllReplicas(expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.jackson
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.{ LogCapturing, ScalaTestWithActorTestKit, SerializationTestKit }
|
||||
import pekko.persistence.typed.ReplicaId
|
||||
import pekko.persistence.typed.crdt.{ Counter, LwwTime, ORSet }
|
||||
import pekko.persistence.typed.jackson.ReplicatedEventSourcingJacksonSpec.{ WithCounter, WithLwwTime, WithOrSet }
|
||||
import pekko.serialization.jackson.{ JsonSerializable, PekkoSerializationDeserializer, PekkoSerializationSerializer }
|
||||
import com.fasterxml.jackson.databind.annotation.{ JsonDeserialize, JsonSerialize }
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object ReplicatedEventSourcingJacksonSpec {
|
||||
final case class WithLwwTime(lwwTime: LwwTime) extends JsonSerializable
|
||||
final case class WithOrSet(
|
||||
@JsonDeserialize(`using` = classOf[PekkoSerializationDeserializer])
|
||||
@JsonSerialize(`using` = classOf[PekkoSerializationSerializer])
|
||||
orSet: ORSet[String])
|
||||
extends JsonSerializable
|
||||
final case class WithCounter(
|
||||
@JsonDeserialize(`using` = classOf[PekkoSerializationDeserializer])
|
||||
@JsonSerialize(`using` = classOf[PekkoSerializationSerializer])
|
||||
counter: Counter)
|
||||
extends JsonSerializable
|
||||
|
||||
}
|
||||
|
||||
class ReplicatedEventSourcingJacksonSpec extends ScalaTestWithActorTestKit with AnyWordSpecLike with LogCapturing {
|
||||
|
||||
private val serializationTestkit = new SerializationTestKit(system)
|
||||
|
||||
"RES jackson" should {
|
||||
"serialize LwwTime" in {
|
||||
val obj = WithLwwTime(LwwTime(5, ReplicaId("A")))
|
||||
serializationTestkit.verifySerialization(obj)
|
||||
}
|
||||
"serialize ORSet" in {
|
||||
val emptyOrSet = WithOrSet(ORSet.empty[String](ReplicaId("A")))
|
||||
serializationTestkit.verifySerialization(emptyOrSet)
|
||||
}
|
||||
"serialize Counter" in {
|
||||
val counter = WithCounter(Counter.empty)
|
||||
serializationTestkit.verifySerialization(counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.TestException
|
||||
import pekko.actor.testkit.typed.TestKitSettings
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.PostStop
|
||||
import pekko.actor.typed.PreRestart
|
||||
import pekko.actor.typed.Signal
|
||||
import pekko.actor.typed.SupervisorStrategy
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.actor.typed.scaladsl.adapter._
|
||||
import pekko.persistence.AtomicWrite
|
||||
import pekko.persistence.journal.inmem.InmemJournal
|
||||
import pekko.persistence.typed.EventRejectedException
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.typed.RecoveryFailed
|
||||
import pekko.persistence.typed.internal.JournalFailureException
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import scala.collection.immutable
|
||||
import scala.concurrent.Future
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.Try
|
||||
|
||||
class ChaosJournal extends InmemJournal {
|
||||
var counts = Map.empty[String, Int]
|
||||
var failRecovery = true
|
||||
var reject = true
|
||||
|
||||
override def asyncWriteMessages(messages: immutable.Seq[AtomicWrite]): Future[immutable.Seq[Try[Unit]]] = {
|
||||
val pid = messages.head.persistenceId
|
||||
counts = counts.updated(pid, counts.getOrElse(pid, 0) + 1)
|
||||
if (pid == "fail-first-2" && counts(pid) <= 2) {
|
||||
Future.failed(TestException("database says no"))
|
||||
} else if (pid.startsWith("fail-fifth") && counts(pid) == 5) {
|
||||
Future.failed(TestException("database says no"))
|
||||
} else if (pid == "reject-first" && reject) {
|
||||
reject = false
|
||||
Future.successful(messages.map(_ =>
|
||||
Try {
|
||||
throw TestException("I don't like it")
|
||||
}))
|
||||
} else if (messages.head.payload.head.payload == "malicious") {
|
||||
super.asyncWriteMessages(List(AtomicWrite(List(messages.head.payload.head.withPayload("wrong-event")))))
|
||||
} else {
|
||||
super.asyncWriteMessages(messages)
|
||||
}
|
||||
}
|
||||
|
||||
override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = {
|
||||
if (persistenceId == "fail-recovery-once" && failRecovery) {
|
||||
failRecovery = false
|
||||
Future.failed(TestException("Nah"))
|
||||
} else if (persistenceId == "fail-recovery") {
|
||||
Future.failed(TestException("Nope"))
|
||||
} else {
|
||||
super.asyncReadHighestSequenceNr(persistenceId, fromSequenceNr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EventSourcedBehaviorFailureSpec {
|
||||
|
||||
val conf: Config = ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
pekko.persistence.journal.plugin = "failure-journal"
|
||||
failure-journal = $${pekko.persistence.journal.inmem}
|
||||
failure-journal {
|
||||
class = "org.apache.pekko.persistence.typed.scaladsl.ChaosJournal"
|
||||
}
|
||||
""").withFallback(ConfigFactory.defaultReference()).resolve()
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorFailureSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedBehaviorFailureSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
implicit val testSettings: TestKitSettings = TestKitSettings(system)
|
||||
|
||||
def failingPersistentActor(
|
||||
pid: PersistenceId,
|
||||
probe: ActorRef[String],
|
||||
additionalSignalHandler: PartialFunction[(String, Signal), Unit] = PartialFunction.empty)
|
||||
: EventSourcedBehavior[String, String, String] =
|
||||
EventSourcedBehavior[String, String, String](
|
||||
pid,
|
||||
"",
|
||||
(_, cmd) => {
|
||||
if (cmd == "wrong")
|
||||
throw TestException("wrong command")
|
||||
probe.tell("persisting")
|
||||
Effect.persist(cmd).thenRun { _ =>
|
||||
probe.tell("persisted")
|
||||
if (cmd == "wrong-callback") throw TestException("wrong command")
|
||||
}
|
||||
},
|
||||
(state, event) => {
|
||||
if (event == "wrong-event")
|
||||
throw TestException("wrong event")
|
||||
probe.tell(event)
|
||||
state + event
|
||||
}).receiveSignal(additionalSignalHandler.orElse {
|
||||
case (_, RecoveryCompleted) =>
|
||||
probe.tell("starting")
|
||||
case (_, PostStop) =>
|
||||
probe.tell("stopped")
|
||||
case (_, PreRestart) =>
|
||||
probe.tell("restarting")
|
||||
})
|
||||
|
||||
"A typed persistent actor (failures)" must {
|
||||
|
||||
"signal RecoveryFailure when replay fails" in {
|
||||
LoggingTestKit.error[JournalFailureException].expect {
|
||||
val probe = TestProbe[String]()
|
||||
val excProbe = TestProbe[Throwable]()
|
||||
spawn(failingPersistentActor(PersistenceId.ofUniqueId("fail-recovery"), probe.ref,
|
||||
{
|
||||
case (_, RecoveryFailed(t)) =>
|
||||
excProbe.ref ! t
|
||||
}))
|
||||
|
||||
excProbe.expectMessageType[TestException].message shouldEqual "Nope"
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
|
||||
"handle exceptions from RecoveryFailed signal handler" in {
|
||||
val probe = TestProbe[String]()
|
||||
val pa = spawn(failingPersistentActor(PersistenceId.ofUniqueId("fail-recovery-twice"), probe.ref,
|
||||
{
|
||||
case (_, RecoveryFailed(_)) =>
|
||||
throw TestException("recovery call back failure")
|
||||
}))
|
||||
pa ! "one"
|
||||
probe.expectMessage("starting")
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("one")
|
||||
probe.expectMessage("persisted")
|
||||
}
|
||||
|
||||
"signal RecoveryFailure when event handler throws during replay" in {
|
||||
val probe = TestProbe[String]()
|
||||
val excProbe = TestProbe[Throwable]()
|
||||
val pid = PersistenceId.ofUniqueId("wrong-event-1")
|
||||
val ref = spawn(failingPersistentActor(pid, probe.ref))
|
||||
|
||||
ref ! "malicious"
|
||||
probe.expectMessage("starting")
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("malicious")
|
||||
probe.expectMessage("persisted")
|
||||
|
||||
LoggingTestKit.error[JournalFailureException].expect {
|
||||
// start again and then the event handler will throw
|
||||
spawn(failingPersistentActor(pid, probe.ref,
|
||||
{
|
||||
case (_, RecoveryFailed(t)) =>
|
||||
excProbe.ref ! t
|
||||
}))
|
||||
|
||||
excProbe.expectMessageType[TestException].message shouldEqual "wrong event"
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
|
||||
"fail recovery if exception from RecoveryCompleted signal handler" in {
|
||||
val probe = TestProbe[String]()
|
||||
LoggingTestKit.error[JournalFailureException].expect {
|
||||
spawn(
|
||||
Behaviors
|
||||
.supervise(failingPersistentActor(
|
||||
PersistenceId.ofUniqueId("recovery-ok"),
|
||||
probe.ref,
|
||||
{
|
||||
case (_, RecoveryCompleted) =>
|
||||
probe.ref.tell("starting")
|
||||
throw TestException("recovery call back failure")
|
||||
}))
|
||||
// since recovery fails restart supervision is not supposed to be used
|
||||
.onFailure(SupervisorStrategy.restart))
|
||||
probe.expectMessage("starting")
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
|
||||
"restart with backoff" in {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = failingPersistentActor(PersistenceId.ofUniqueId("fail-first-2"), probe.ref).onPersistFailure(
|
||||
SupervisorStrategy.restartWithBackoff(1.milli, 10.millis, 0.1).withLoggingEnabled(enabled = false))
|
||||
val c = spawn(behav)
|
||||
probe.expectMessage("starting")
|
||||
// fail
|
||||
c ! "one"
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("one")
|
||||
probe.expectMessage("restarting")
|
||||
probe.expectMessage("starting")
|
||||
// fail
|
||||
c ! "two"
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("two")
|
||||
probe.expectMessage("restarting")
|
||||
probe.expectMessage("starting")
|
||||
// work!
|
||||
c ! "three"
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("three")
|
||||
probe.expectMessage("persisted")
|
||||
// no restart
|
||||
probe.expectNoMessage()
|
||||
}
|
||||
|
||||
"restart with backoff for recovery" in {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = failingPersistentActor(PersistenceId.ofUniqueId("fail-recovery-once"), probe.ref).onPersistFailure(
|
||||
SupervisorStrategy.restartWithBackoff(1.milli, 10.millis, 0.1).withLoggingEnabled(enabled = false))
|
||||
spawn(behav)
|
||||
// First time fails, second time should work and call onRecoveryComplete
|
||||
probe.expectMessage("restarting")
|
||||
probe.expectMessage("starting")
|
||||
probe.expectNoMessage()
|
||||
}
|
||||
|
||||
"handles rejections" in {
|
||||
val probe = TestProbe[String]()
|
||||
val behav =
|
||||
Behaviors
|
||||
.supervise(failingPersistentActor(PersistenceId.ofUniqueId("reject-first"), probe.ref))
|
||||
.onFailure[EventRejectedException](
|
||||
SupervisorStrategy.restartWithBackoff(1.milli, 5.millis, 0.1).withLoggingEnabled(enabled = false))
|
||||
val c = spawn(behav)
|
||||
// First time fails, second time should work and call onRecoveryComplete
|
||||
probe.expectMessage("starting")
|
||||
c ! "one"
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("one")
|
||||
probe.expectMessage("restarting")
|
||||
probe.expectMessage("starting")
|
||||
c ! "two"
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("two")
|
||||
probe.expectMessage("persisted")
|
||||
// no restart
|
||||
probe.expectNoMessage()
|
||||
}
|
||||
|
||||
"stop (default supervisor strategy) if command handler throws" in {
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = failingPersistentActor(PersistenceId.ofUniqueId("wrong-command-1"), probe.ref)
|
||||
val c = spawn(behav)
|
||||
probe.expectMessage("starting")
|
||||
c ! "wrong"
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
|
||||
"restart supervisor strategy if command handler throws" in {
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = Behaviors
|
||||
.supervise(failingPersistentActor(PersistenceId.ofUniqueId("wrong-command-2"), probe.ref))
|
||||
.onFailure[TestException](SupervisorStrategy.restart)
|
||||
val c = spawn(behav)
|
||||
probe.expectMessage("starting")
|
||||
c ! "wrong"
|
||||
probe.expectMessage("restarting")
|
||||
}
|
||||
}
|
||||
|
||||
"stop (default supervisor strategy) if side effect callback throws" in {
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = failingPersistentActor(PersistenceId.ofUniqueId("wrong-command-3"), probe.ref)
|
||||
val c = spawn(behav)
|
||||
probe.expectMessage("starting")
|
||||
c ! "wrong-callback"
|
||||
probe.expectMessage("persisting")
|
||||
probe.expectMessage("wrong-callback") // from event handler
|
||||
probe.expectMessage("persisted")
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
|
||||
"stop (default supervisor strategy) if signal handler throws" in {
|
||||
case object SomeSignal extends Signal
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = failingPersistentActor(PersistenceId.ofUniqueId("wrong-signal-handler"), probe.ref,
|
||||
{
|
||||
case (_, SomeSignal) => throw TestException("from signal")
|
||||
})
|
||||
val c = spawn(behav)
|
||||
probe.expectMessage("starting")
|
||||
c.toClassic ! SomeSignal
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
|
||||
"not accept wrong event, before persisting it" in {
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
val probe = TestProbe[String]()
|
||||
val behav = failingPersistentActor(PersistenceId.ofUniqueId("wrong-event-2"), probe.ref)
|
||||
val c = spawn(behav)
|
||||
probe.expectMessage("starting")
|
||||
// event handler will throw for this event
|
||||
c ! "wrong-event"
|
||||
probe.expectMessage("persisting")
|
||||
// but not "persisted"
|
||||
probe.expectMessage("stopped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.BehaviorInterceptor
|
||||
import pekko.actor.typed.TypedActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object EventSourcedBehaviorInterceptorSpec {
|
||||
|
||||
val journalId = "event-sourced-behavior-interceptor-spec"
|
||||
|
||||
def config: Config = ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
pekko.persistence.journal.inmem.test-serialization = on
|
||||
""")
|
||||
|
||||
def testBehavior(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
EventSourcedBehavior[String, String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) =>
|
||||
command match {
|
||||
case _ =>
|
||||
Effect.persist(command).thenRun(newState => probe ! newState)
|
||||
},
|
||||
eventHandler = (state, evt) => state + evt)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorInterceptorSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorInterceptorSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"EventSourcedBehavior interceptor" must {
|
||||
|
||||
"be possible to combine with another interceptor" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
|
||||
val toUpper = new BehaviorInterceptor[String, String] {
|
||||
override def aroundReceive(
|
||||
ctx: TypedActorContext[String],
|
||||
msg: String,
|
||||
target: BehaviorInterceptor.ReceiveTarget[String]): Behavior[String] = {
|
||||
target(ctx, msg.toUpperCase())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val ref = spawn(Behaviors.intercept(() => toUpper)(testBehavior(pid, probe.ref)))
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("A")
|
||||
probe.expectMessage("ABC")
|
||||
}
|
||||
|
||||
"be possible to combine with transformMessages" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testBehavior(pid, probe.ref).transformMessages[String] {
|
||||
case s => s.toUpperCase()
|
||||
})
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("A")
|
||||
probe.expectMessage("ABC")
|
||||
}
|
||||
|
||||
"be possible to combine with MDC" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(Behaviors.setup[String] { _ =>
|
||||
Behaviors.withMdc(
|
||||
staticMdc = Map("pid" -> pid.toString),
|
||||
mdcForMessage = (msg: String) => Map("msg" -> msg.toUpperCase())) {
|
||||
testBehavior(pid, probe.ref)
|
||||
}
|
||||
})
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("a")
|
||||
probe.expectMessage("abc")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.RecoveryTimedOut
|
||||
import pekko.persistence.journal.SteppingInmemJournal
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryFailed
|
||||
import pekko.persistence.typed.internal.JournalFailureException
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import scala.concurrent.duration._
|
||||
|
||||
object EventSourcedBehaviorRecoveryTimeoutSpec {
|
||||
|
||||
val journalId = "event-sourced-behavior-recovery-timeout-spec"
|
||||
|
||||
def config: Config =
|
||||
SteppingInmemJournal
|
||||
.config(journalId)
|
||||
.withFallback(ConfigFactory.parseString("""
|
||||
pekko.persistence.journal.stepping-inmem.recovery-event-timeout=1s
|
||||
"""))
|
||||
.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
|
||||
def testBehavior(persistenceId: PersistenceId, probe: ActorRef[AnyRef]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
EventSourcedBehavior[String, String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) => Effect.persist(command).thenRun(_ => probe ! command),
|
||||
eventHandler = (state, evt) => state + evt).receiveSignal {
|
||||
case (_, RecoveryFailed(cause)) =>
|
||||
probe ! cause
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorRecoveryTimeoutSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedBehaviorRecoveryTimeoutSpec.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorRecoveryTimeoutSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
import org.apache.pekko.actor.typed.scaladsl.adapter._
|
||||
// needed for SteppingInmemJournal.step
|
||||
private implicit val classicSystem: pekko.actor.ActorSystem = system.toClassic
|
||||
|
||||
"The recovery timeout" must {
|
||||
|
||||
"fail recovery if timeout is not met when recovering" in {
|
||||
val probe = createTestProbe[AnyRef]()
|
||||
val pid = nextPid()
|
||||
val persisting = spawn(testBehavior(pid, probe.ref))
|
||||
|
||||
probe.awaitAssert(SteppingInmemJournal.getRef(journalId), 3.seconds)
|
||||
val journal = SteppingInmemJournal.getRef(journalId)
|
||||
|
||||
// initial read highest
|
||||
SteppingInmemJournal.step(journal)
|
||||
|
||||
persisting ! "A"
|
||||
SteppingInmemJournal.step(journal)
|
||||
probe.expectMessage("A")
|
||||
|
||||
testKit.stop(persisting)
|
||||
probe.expectTerminated(persisting)
|
||||
|
||||
// now replay, but don't give the journal any tokens to replay events
|
||||
// so that we cause the timeout to trigger
|
||||
LoggingTestKit
|
||||
.error[JournalFailureException]
|
||||
.withMessageRegex("Exception during recovery.*Replay timed out")
|
||||
.expect {
|
||||
val replaying = spawn(testBehavior(pid, probe.ref))
|
||||
|
||||
// initial read highest
|
||||
SteppingInmemJournal.step(journal)
|
||||
|
||||
probe.expectMessageType[RecoveryTimedOut]
|
||||
probe.expectTerminated(replaying)
|
||||
}
|
||||
|
||||
// avoid having it stuck in the next test from the
|
||||
// last read request above
|
||||
SteppingInmemJournal.step(journal)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object EventSourcedBehaviorReplySpec {
|
||||
|
||||
sealed trait Command[ReplyMessage] extends CborSerializable
|
||||
final case class IncrementWithConfirmation(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
final case class IncrementReplyLater(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
final case class ReplyNow(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
final case class GetValue(replyTo: ActorRef[State]) extends Command[State]
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class Incremented(delta: Int) extends Event
|
||||
|
||||
final case class State(value: Int, history: Vector[Int]) extends CborSerializable
|
||||
|
||||
def counter(persistenceId: PersistenceId): Behavior[Command[_]] =
|
||||
Behaviors.setup(ctx => counter(ctx, persistenceId))
|
||||
|
||||
def counter(
|
||||
ctx: ActorContext[Command[_]],
|
||||
persistenceId: PersistenceId): EventSourcedBehavior[Command[_], Event, State] = {
|
||||
EventSourcedBehavior.withEnforcedReplies[Command[_], Event, State](
|
||||
persistenceId,
|
||||
emptyState = State(0, Vector.empty),
|
||||
commandHandler = (state, command) =>
|
||||
command match {
|
||||
|
||||
case IncrementWithConfirmation(replyTo) =>
|
||||
Effect.persist(Incremented(1)).thenReply(replyTo)(_ => Done)
|
||||
|
||||
case IncrementReplyLater(replyTo) =>
|
||||
Effect.persist(Incremented(1)).thenRun((_: State) => ctx.self ! ReplyNow(replyTo)).thenNoReply()
|
||||
|
||||
case ReplyNow(replyTo) =>
|
||||
Effect.reply(replyTo)(Done)
|
||||
|
||||
case GetValue(replyTo) =>
|
||||
Effect.reply(replyTo)(state)
|
||||
|
||||
},
|
||||
eventHandler = (state, evt) =>
|
||||
evt match {
|
||||
case Incremented(delta) =>
|
||||
State(state.value + delta, state.history :+ state.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorReplySpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorReplySpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"A typed persistent actor with commands that are expecting replies" must {
|
||||
|
||||
"persist an event thenReply" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[Done]()
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
probe.expectMessage(Done)
|
||||
}
|
||||
|
||||
"persist an event thenReply later" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[Done]()
|
||||
c ! IncrementReplyLater(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
}
|
||||
|
||||
"reply to query command" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val updateProbe = TestProbe[Done]()
|
||||
c ! IncrementWithConfirmation(updateProbe.ref)
|
||||
|
||||
val queryProbe = TestProbe[State]()
|
||||
c ! GetValue(queryProbe.ref)
|
||||
queryProbe.expectMessage(State(1, Vector(0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.PersistenceTestKitSnapshotPlugin
|
||||
import pekko.persistence.typed.DeleteEventsCompleted
|
||||
import pekko.persistence.typed.DeleteSnapshotsCompleted
|
||||
import pekko.persistence.typed.DeleteSnapshotsFailed
|
||||
import pekko.persistence.typed.DeletionTarget
|
||||
import pekko.persistence.typed.EventSourcedSignal
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.typed.SnapshotCompleted
|
||||
import pekko.persistence.typed.SnapshotFailed
|
||||
import pekko.persistence.typed.SnapshotSelectionCriteria
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import pekko.util.unused
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.Success
|
||||
import scala.util.Try
|
||||
|
||||
object EventSourcedBehaviorRetentionSpec extends Matchers {
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
case object Increment extends Command
|
||||
final case class IncrementWithPersistAll(nr: Int) extends Command
|
||||
final case class GetValue(replyTo: ActorRef[State]) extends Command
|
||||
case object StopIt extends Command
|
||||
|
||||
final case class WrappedSignal(signal: EventSourcedSignal)
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class Incremented(delta: Int) extends Event
|
||||
|
||||
final case class State(value: Int, history: Vector[Int]) extends CborSerializable
|
||||
|
||||
def counter(
|
||||
@unused ctx: ActorContext[Command],
|
||||
persistenceId: PersistenceId,
|
||||
probe: Option[ActorRef[(State, Event)]] = None,
|
||||
snapshotSignalProbe: Option[ActorRef[WrappedSignal]] = None,
|
||||
eventSignalProbe: Option[ActorRef[Try[EventSourcedSignal]]] = None)
|
||||
: EventSourcedBehavior[Command, Event, State] = {
|
||||
EventSourcedBehavior[Command, Event, State](
|
||||
persistenceId,
|
||||
emptyState = State(0, Vector.empty),
|
||||
commandHandler = (state, cmd) =>
|
||||
cmd match {
|
||||
case Increment =>
|
||||
Effect.persist(Incremented(1))
|
||||
|
||||
case IncrementWithPersistAll(n) =>
|
||||
Effect.persist((0 until n).map(_ => Incremented(1)))
|
||||
|
||||
case GetValue(replyTo) =>
|
||||
replyTo ! state
|
||||
Effect.none
|
||||
|
||||
case StopIt =>
|
||||
Effect.none.thenStop()
|
||||
|
||||
},
|
||||
eventHandler = (state, evt) =>
|
||||
evt match {
|
||||
case Incremented(delta) =>
|
||||
probe.foreach(_ ! ((state, evt)))
|
||||
State(state.value + delta, state.history :+ state.value)
|
||||
}).receiveSignal {
|
||||
case (_, RecoveryCompleted) => ()
|
||||
case (_, sc: SnapshotCompleted) =>
|
||||
snapshotSignalProbe.foreach(_ ! WrappedSignal(sc))
|
||||
case (_, sf: SnapshotFailed) =>
|
||||
snapshotSignalProbe.foreach(_ ! WrappedSignal(sf))
|
||||
case (_, dc: DeleteSnapshotsCompleted) =>
|
||||
snapshotSignalProbe.foreach(_ ! WrappedSignal(dc))
|
||||
case (_, dsf: DeleteSnapshotsFailed) =>
|
||||
snapshotSignalProbe.foreach(_ ! WrappedSignal(dsf))
|
||||
case (_, e: EventSourcedSignal) =>
|
||||
eventSignalProbe.foreach(_ ! Success(e))
|
||||
}
|
||||
}
|
||||
|
||||
implicit class WrappedSignalProbeAssert(probe: TestProbe[WrappedSignal]) {
|
||||
def expectSnapshotCompleted(sequenceNumber: Int): SnapshotCompleted = {
|
||||
val wrapped = probe.expectMessageType[WrappedSignal]
|
||||
wrapped.signal shouldBe a[SnapshotCompleted]
|
||||
val completed = wrapped.signal.asInstanceOf[SnapshotCompleted]
|
||||
completed.metadata.sequenceNr should ===(sequenceNumber)
|
||||
completed
|
||||
}
|
||||
|
||||
def expectDeleteSnapshotCompleted(maxSequenceNr: Long, minSequenceNr: Long): DeleteSnapshotsCompleted = {
|
||||
val wrapped = probe.expectMessageType[WrappedSignal]
|
||||
wrapped.signal shouldBe a[DeleteSnapshotsCompleted]
|
||||
val signal = wrapped.signal.asInstanceOf[DeleteSnapshotsCompleted]
|
||||
signal.target should ===(
|
||||
DeletionTarget.Criteria(
|
||||
SnapshotSelectionCriteria.latest.withMaxSequenceNr(maxSequenceNr).withMinSequenceNr(minSequenceNr)))
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorRetentionSpec
|
||||
extends ScalaTestWithActorTestKit(
|
||||
PersistenceTestKitPlugin.config.withFallback(PersistenceTestKitSnapshotPlugin.config))
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorRetentionSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"EventSourcedBehavior with retention" must {
|
||||
|
||||
"snapshot every N sequence nrs" in {
|
||||
val pid = nextPid()
|
||||
val c = spawn(Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid).withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 2, keepNSnapshots = 2))))
|
||||
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
c ! StopIt
|
||||
val watchProbe = TestProbe()
|
||||
watchProbe.expectTerminated(c)
|
||||
|
||||
// no snapshot should have happened
|
||||
val probeC2 = TestProbe[(State, Event)]()
|
||||
val snapshotProbe = createTestProbe[WrappedSignal]()
|
||||
|
||||
val c2 = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, probe = Some(probeC2.ref), snapshotSignalProbe = Some(snapshotProbe.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 2, keepNSnapshots = 2))))
|
||||
probeC2.expectMessage[(State, Event)]((State(0, Vector()), Incremented(1)))
|
||||
|
||||
c2 ! Increment
|
||||
snapshotProbe.expectSnapshotCompleted(2)
|
||||
c2 ! StopIt
|
||||
watchProbe.expectTerminated(c2)
|
||||
|
||||
val probeC3 = TestProbe[(State, Event)]()
|
||||
val c3 = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, Some(probeC3.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 2, keepNSnapshots = 2))))
|
||||
// this time it should have been snapshotted so no events to replay
|
||||
probeC3.expectNoMessage()
|
||||
c3 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(2, Vector(0, 1)))
|
||||
}
|
||||
|
||||
"snapshot every N sequence nrs when persisting multiple events" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val c =
|
||||
spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, None, Some(snapshotSignalProbe.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 2, keepNSnapshots = 2))))
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! IncrementWithPersistAll(3)
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
// snapshot at seqNr 3 because of persistAll
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
c ! StopIt
|
||||
val watchProbe = TestProbe()
|
||||
watchProbe.expectTerminated(c)
|
||||
|
||||
val probeC2 = TestProbe[(State, Event)]()
|
||||
val c2 = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, Some(probeC2.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 2, keepNSnapshots = 2))))
|
||||
probeC2.expectNoMessage()
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
}
|
||||
|
||||
"snapshot via predicate" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val alwaysSnapshot: Behavior[Command] =
|
||||
Behaviors.setup { ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref)).snapshotWhen { (_, _, _) =>
|
||||
true
|
||||
}
|
||||
}
|
||||
val c = spawn(alwaysSnapshot)
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment
|
||||
snapshotSignalProbe.expectSnapshotCompleted(1)
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
c ! StopIt
|
||||
val watchProbe = TestProbe()
|
||||
watchProbe.expectTerminated(c)
|
||||
|
||||
val probe = TestProbe[(State, Event)]()
|
||||
val c2 = spawn(Behaviors.setup[Command](ctx => counter(ctx, pid, Some(probe.ref))))
|
||||
// state should be rebuilt from snapshot, no events replayed
|
||||
// Fails as snapshot is async (i think)
|
||||
probe.expectNoMessage()
|
||||
c2 ! Increment
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(2, Vector(0, 1)))
|
||||
}
|
||||
|
||||
"check all events for snapshot in PersistAll" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val snapshotAtTwo = Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref)).snapshotWhen { (s, _, _) =>
|
||||
s.value == 2
|
||||
})
|
||||
val c: ActorRef[Command] = spawn(snapshotAtTwo)
|
||||
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! IncrementWithPersistAll(3)
|
||||
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
// snapshot at seqNr 3 because of persistAll
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
c ! StopIt
|
||||
val watchProbe = TestProbe()
|
||||
watchProbe.expectTerminated(c)
|
||||
|
||||
val probeC2 = TestProbe[(State, Event)]()
|
||||
val c2 = spawn(Behaviors.setup[Command](ctx => counter(ctx, pid, Some(probeC2.ref))))
|
||||
// middle event triggered all to be snapshot
|
||||
probeC2.expectNoMessage()
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
}
|
||||
|
||||
"delete snapshots automatically, based on criteria" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
val persistentActor = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 3, keepNSnapshots = 2))))
|
||||
|
||||
(1 to 10).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(10, (0 until 10).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(6)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(9)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(3, 0)
|
||||
|
||||
(1 to 10).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(20, (0 until 20).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(12)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(6, 0)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(15)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(9, 3)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(18)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(12, 6)
|
||||
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
|
||||
"optionally delete both old events and snapshots" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val eventProbe = TestProbe[Try[EventSourcedSignal]]()
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
val persistentActor = spawn(Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref), eventSignalProbe = Some(eventProbe.ref))
|
||||
.withRetention(
|
||||
// tests the Java API as well
|
||||
RetentionCriteria.snapshotEvery(numberOfEvents = 3, keepNSnapshots = 2).withDeleteEventsOnSnapshot)))
|
||||
|
||||
(1 to 10).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(10, (0 until 10).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(6)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(9)
|
||||
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 3
|
||||
// Note that when triggering deletion of snapshots from deletion of events it is intentionally "off by one".
|
||||
// The reason for -1 is that a snapshot at the exact toSequenceNr is still useful and the events
|
||||
// after that can be replayed after that snapshot, but replaying the events after toSequenceNr without
|
||||
// starting at the snapshot at toSequenceNr would be invalid.
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(2, 0)
|
||||
|
||||
// one at a time since snapshotting+event-deletion switches to running state before deleting snapshot so ordering
|
||||
// if sending many commands in one go is not deterministic
|
||||
persistentActor ! Increment // 11
|
||||
persistentActor ! Increment // 12
|
||||
snapshotSignalProbe.expectSnapshotCompleted(12)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 6
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(5, 0)
|
||||
|
||||
persistentActor ! Increment // 13
|
||||
persistentActor ! Increment // 14
|
||||
persistentActor ! Increment // 11
|
||||
persistentActor ! Increment // 15
|
||||
snapshotSignalProbe.expectSnapshotCompleted(15)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 9
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(8, 2)
|
||||
|
||||
persistentActor ! Increment // 16
|
||||
persistentActor ! Increment // 17
|
||||
persistentActor ! Increment // 18
|
||||
snapshotSignalProbe.expectSnapshotCompleted(18)
|
||||
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 12
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(11, 5)
|
||||
|
||||
eventProbe.expectNoMessage()
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
|
||||
"be possible to combine snapshotWhen and retention criteria" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val eventProbe = TestProbe[Try[EventSourcedSignal]]()
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
val persistentActor = spawn(Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref), eventSignalProbe = Some(eventProbe.ref))
|
||||
.snapshotWhen((_, _, seqNr) => seqNr == 3 || seqNr == 13)
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 5, keepNSnapshots = 1))))
|
||||
|
||||
(1 to 3).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(3, (0 until 3).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
eventProbe.expectNoMessage()
|
||||
|
||||
(4 to 10).foreach(_ => persistentActor ! Increment)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(5)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(10)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(5, 0)
|
||||
|
||||
(11 to 13).foreach(_ => persistentActor ! Increment)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(13)
|
||||
// no deletes triggered by snapshotWhen
|
||||
eventProbe.within(3.seconds) {
|
||||
eventProbe.expectNoMessage()
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
|
||||
(14 to 16).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(16, (0 until 16).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(15)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(10, 5)
|
||||
eventProbe.within(3.seconds) {
|
||||
eventProbe.expectNoMessage()
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
}
|
||||
|
||||
"be possible to combine snapshotWhen and retention criteria withDeleteEventsOnSnapshot" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val eventProbe = TestProbe[Try[EventSourcedSignal]]()
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
val persistentActor = spawn(Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref), eventSignalProbe = Some(eventProbe.ref))
|
||||
.snapshotWhen((_, _, seqNr) => seqNr == 3 || seqNr == 13)
|
||||
.withRetention(
|
||||
RetentionCriteria.snapshotEvery(numberOfEvents = 2, keepNSnapshots = 3).withDeleteEventsOnSnapshot)))
|
||||
|
||||
(1 to 3).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(3, (0 until 3).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(2) // every-2 through criteria
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3) // snapshotWhen
|
||||
// no event deletes or snapshot deletes after snapshotWhen
|
||||
eventProbe.within(3.seconds) {
|
||||
eventProbe.expectNoMessage()
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
|
||||
// one at a time since snapshotting+event-deletion switches to running state before deleting snapshot so ordering
|
||||
// if sending many commands in one go is not deterministic
|
||||
persistentActor ! Increment // 4
|
||||
snapshotSignalProbe.expectSnapshotCompleted(4) // every-2 through criteria
|
||||
persistentActor ! Increment // 5
|
||||
persistentActor ! Increment // 6
|
||||
snapshotSignalProbe.expectSnapshotCompleted(6) // every-2 through criteria
|
||||
|
||||
persistentActor ! Increment // 7
|
||||
persistentActor ! Increment // 8
|
||||
snapshotSignalProbe.expectSnapshotCompleted(8) // every-2 through criteria
|
||||
// triggers delete up to snapshot no 2
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 2
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(1, 0) // then delete oldest snapshot
|
||||
|
||||
persistentActor ! Increment // 9
|
||||
persistentActor ! Increment // 10
|
||||
snapshotSignalProbe.expectSnapshotCompleted(10) // every-2 through criteria
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(3, 0)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 4
|
||||
|
||||
persistentActor ! Increment // 11
|
||||
persistentActor ! Increment // 12
|
||||
snapshotSignalProbe.expectSnapshotCompleted(12) // every-2 through criteria
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(5, 0)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 6
|
||||
|
||||
persistentActor ! Increment // 13
|
||||
snapshotSignalProbe.expectSnapshotCompleted(13) // snapshotWhen
|
||||
// no deletes triggered by snapshotWhen
|
||||
eventProbe.within(3.seconds) {
|
||||
eventProbe.expectNoMessage()
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
|
||||
persistentActor ! Increment // 14
|
||||
snapshotSignalProbe.expectSnapshotCompleted(14) // every-2 through criteria
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 8
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(7, 1)
|
||||
|
||||
persistentActor ! Increment // 15
|
||||
persistentActor ! Increment // 16
|
||||
snapshotSignalProbe.expectSnapshotCompleted(16) // every-2 through criteria
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 10
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(9, 3)
|
||||
|
||||
eventProbe.within(3.seconds) {
|
||||
eventProbe.expectNoMessage()
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
}
|
||||
}
|
||||
|
||||
"be possible to snapshot every event" in {
|
||||
// very bad idea to snapshot every event, but technically possible
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
val persistentActor = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 1, keepNSnapshots = 3))))
|
||||
|
||||
(1 to 10).foreach(_ => persistentActor ! Increment)
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(10, (0 until 10).toVector))
|
||||
snapshotSignalProbe.expectSnapshotCompleted(1)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(2)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(4)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(1, 0)
|
||||
|
||||
snapshotSignalProbe.expectSnapshotCompleted(5)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(2, 0)
|
||||
|
||||
snapshotSignalProbe.expectSnapshotCompleted(6)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(3, 0)
|
||||
|
||||
snapshotSignalProbe.expectSnapshotCompleted(7)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(4, 1)
|
||||
|
||||
snapshotSignalProbe.expectSnapshotCompleted(8)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(5, 2)
|
||||
|
||||
snapshotSignalProbe.expectSnapshotCompleted(9)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(6, 3)
|
||||
|
||||
snapshotSignalProbe.expectSnapshotCompleted(10)
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(7, 4)
|
||||
}
|
||||
|
||||
"be possible to snapshot every event withDeleteEventsOnSnapshot" in {
|
||||
// very bad idea to snapshot every event, but technically possible
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
val eventProbe = TestProbe[Try[EventSourcedSignal]]()
|
||||
|
||||
val persistentActor = spawn(Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref), eventSignalProbe = Some(eventProbe.ref))
|
||||
.withRetention(
|
||||
RetentionCriteria.snapshotEvery(numberOfEvents = 1, keepNSnapshots = 3).withDeleteEventsOnSnapshot)))
|
||||
|
||||
// one at a time since snapshotting+event-deletion switches to running state before deleting snapshot so ordering
|
||||
// if sending many commands in one go is not deterministic
|
||||
(1 to 4).foreach(_ => persistentActor ! Increment)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(1)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(2)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(3)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(4)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 1
|
||||
|
||||
persistentActor ! Increment // 5
|
||||
snapshotSignalProbe.expectSnapshotCompleted(5)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 2
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(1, 0)
|
||||
|
||||
persistentActor ! Increment // 6
|
||||
snapshotSignalProbe.expectSnapshotCompleted(6)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 3
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(2, 0)
|
||||
|
||||
persistentActor ! Increment // 7
|
||||
snapshotSignalProbe.expectSnapshotCompleted(7)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 4
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(3, 0)
|
||||
|
||||
persistentActor ! Increment // 8
|
||||
snapshotSignalProbe.expectSnapshotCompleted(8)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 5
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(4, 1)
|
||||
|
||||
persistentActor ! Increment // 9
|
||||
snapshotSignalProbe.expectSnapshotCompleted(9)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 6
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(5, 2)
|
||||
|
||||
persistentActor ! Increment // 10
|
||||
snapshotSignalProbe.expectSnapshotCompleted(10)
|
||||
eventProbe.expectMessageType[Success[DeleteEventsCompleted]].value.toSequenceNr shouldEqual 7
|
||||
snapshotSignalProbe.expectDeleteSnapshotCompleted(6, 3)
|
||||
}
|
||||
|
||||
"snapshot on recovery if expected snapshot is missing" in {
|
||||
val pid = nextPid()
|
||||
val snapshotSignalProbe = TestProbe[WrappedSignal]()
|
||||
|
||||
{
|
||||
val persistentActor =
|
||||
spawn(Behaviors.setup[Command](ctx => counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref))))
|
||||
(1 to 5).foreach(_ => persistentActor ! Increment)
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
|
||||
persistentActor ! StopIt
|
||||
val watchProbe = TestProbe()
|
||||
watchProbe.expectTerminated(persistentActor)
|
||||
}
|
||||
|
||||
{
|
||||
val persistentActor = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 5, keepNSnapshots = 1))))
|
||||
|
||||
val replyProbe = TestProbe[State]()
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
snapshotSignalProbe.expectSnapshotCompleted(5)
|
||||
replyProbe.expectMessage(State(5, Vector(0, 1, 2, 3, 4)))
|
||||
|
||||
persistentActor ! StopIt
|
||||
val watchProbe = TestProbe()
|
||||
watchProbe.expectTerminated(persistentActor)
|
||||
}
|
||||
|
||||
{
|
||||
val persistentActor = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid, snapshotSignalProbe = Some(snapshotSignalProbe.ref))
|
||||
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 5, keepNSnapshots = 1))))
|
||||
|
||||
val replyProbe = TestProbe[State]()
|
||||
persistentActor ! GetValue(replyProbe.ref)
|
||||
snapshotSignalProbe.expectNoMessage()
|
||||
replyProbe.expectMessage(State(5, Vector(0, 1, 2, 3, 4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,740 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.ActorInitializationException
|
||||
import pekko.actor.testkit.typed.TestException
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.ActorSystem
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.SupervisorStrategy
|
||||
import pekko.actor.typed.Terminated
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.SelectedSnapshot
|
||||
import pekko.persistence.journal.inmem.InmemJournal
|
||||
import pekko.persistence.query.EventEnvelope
|
||||
import pekko.persistence.query.Offset
|
||||
import pekko.persistence.query.PersistenceQuery
|
||||
import pekko.persistence.query.Sequence
|
||||
import pekko.persistence.snapshot.SnapshotStore
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.typed.SnapshotCompleted
|
||||
import pekko.persistence.typed.SnapshotFailed
|
||||
import pekko.persistence.typed.SnapshotMetadata
|
||||
import pekko.persistence.typed.SnapshotSelectionCriteria
|
||||
import pekko.persistence.{ SnapshotMetadata => ClassicSnapshotMetadata }
|
||||
import pekko.persistence.{ SnapshotSelectionCriteria => ClassicSnapshotSelectionCriteria }
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import pekko.stream.scaladsl.Sink
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import scala.concurrent.Future
|
||||
import scala.concurrent.Promise
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.Failure
|
||||
import scala.util.Success
|
||||
import scala.util.Try
|
||||
|
||||
object EventSourcedBehaviorSpec {
|
||||
|
||||
class SlowInMemorySnapshotStore extends SnapshotStore {
|
||||
|
||||
private var state = Map.empty[String, (Any, ClassicSnapshotMetadata)]
|
||||
|
||||
override def loadAsync(
|
||||
persistenceId: String,
|
||||
criteria: ClassicSnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = {
|
||||
Promise().future // never completed
|
||||
}
|
||||
|
||||
override def saveAsync(metadata: ClassicSnapshotMetadata, snapshot: Any): Future[Unit] = {
|
||||
state = state.updated(metadata.persistenceId, (snapshot, metadata))
|
||||
Future.successful(())
|
||||
}
|
||||
|
||||
override def deleteAsync(metadata: ClassicSnapshotMetadata): Future[Unit] = {
|
||||
state = state.filterNot { case (k, (_, b)) => k == metadata.persistenceId && b.sequenceNr == metadata.sequenceNr }
|
||||
Future.successful(())
|
||||
}
|
||||
|
||||
override def deleteAsync(persistenceId: String, criteria: ClassicSnapshotSelectionCriteria): Future[Unit] = {
|
||||
val range = criteria.minSequenceNr to criteria.maxSequenceNr
|
||||
state = state.filterNot { case (k, (_, b)) => k == persistenceId && range.contains(b.sequenceNr) }
|
||||
Future.successful(())
|
||||
}
|
||||
}
|
||||
|
||||
// also used from PersistentActorTest, EventSourcedBehaviorWatchSpec
|
||||
def conf: Config = PersistenceTestKitPlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
# pekko.persistence.typed.log-stashing = on
|
||||
pekko.persistence.snapshot-store.plugin = "pekko.persistence.snapshot-store.local"
|
||||
pekko.persistence.snapshot-store.local.dir = "target/typed-persistence-${UUID.randomUUID().toString}"
|
||||
|
||||
slow-snapshot-store.class = "${classOf[SlowInMemorySnapshotStore].getName}"
|
||||
short-recovery-timeout {
|
||||
class = "${classOf[InmemJournal].getName}"
|
||||
recovery-event-timeout = 10 millis
|
||||
}
|
||||
"""))
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
case object Increment extends Command
|
||||
case object IncrementThenLogThenStop extends Command
|
||||
case object IncrementTwiceThenLogThenStop extends Command
|
||||
final case class IncrementWithPersistAll(nr: Int) extends Command
|
||||
case object IncrementLater extends Command
|
||||
case object IncrementAfterReceiveTimeout extends Command
|
||||
case object IncrementTwiceAndThenLog extends Command
|
||||
final case class IncrementWithConfirmation(replyTo: ActorRef[Done]) extends Command
|
||||
case object DoNothingAndThenLog extends Command
|
||||
case object EmptyEventsListAndThenLog extends Command
|
||||
final case class GetValue(replyTo: ActorRef[State]) extends Command
|
||||
case object DelayFinished extends Command
|
||||
private case object Timeout extends Command
|
||||
case object LogThenStop extends Command
|
||||
case object Fail extends Command
|
||||
case object StopIt extends Command
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
final case class Incremented(delta: Int) extends Event
|
||||
|
||||
final case class State(value: Int, history: Vector[Int]) extends CborSerializable
|
||||
|
||||
case object Tick
|
||||
|
||||
val firstLogging = "first logging"
|
||||
val secondLogging = "second logging"
|
||||
|
||||
def counter(persistenceId: PersistenceId)(implicit system: ActorSystem[_]): Behavior[Command] =
|
||||
Behaviors.setup(ctx => counter(ctx, persistenceId))
|
||||
|
||||
def counter(persistenceId: PersistenceId, logging: ActorRef[String])(
|
||||
implicit system: ActorSystem[_]): Behavior[Command] =
|
||||
Behaviors.setup(ctx => counter(ctx, persistenceId, logging))
|
||||
|
||||
def counter(ctx: ActorContext[Command], persistenceId: PersistenceId)(
|
||||
implicit system: ActorSystem[_]): EventSourcedBehavior[Command, Event, State] =
|
||||
counter(
|
||||
ctx,
|
||||
persistenceId,
|
||||
loggingActor = TestProbe[String]().ref,
|
||||
probe = TestProbe[(State, Event)]().ref,
|
||||
snapshotProbe = TestProbe[Try[SnapshotMetadata]]().ref)
|
||||
|
||||
def counter(ctx: ActorContext[Command], persistenceId: PersistenceId, logging: ActorRef[String])(
|
||||
implicit system: ActorSystem[_]): EventSourcedBehavior[Command, Event, State] =
|
||||
counter(
|
||||
ctx,
|
||||
persistenceId,
|
||||
loggingActor = logging,
|
||||
probe = TestProbe[(State, Event)]().ref,
|
||||
TestProbe[Try[SnapshotMetadata]]().ref)
|
||||
|
||||
def counterWithProbe(
|
||||
ctx: ActorContext[Command],
|
||||
persistenceId: PersistenceId,
|
||||
probe: ActorRef[(State, Event)],
|
||||
snapshotProbe: ActorRef[Try[SnapshotMetadata]])(
|
||||
implicit system: ActorSystem[_]): EventSourcedBehavior[Command, Event, State] =
|
||||
counter(ctx, persistenceId, TestProbe[String]().ref, probe, snapshotProbe)
|
||||
|
||||
def counterWithProbe(ctx: ActorContext[Command], persistenceId: PersistenceId, probe: ActorRef[(State, Event)])(
|
||||
implicit system: ActorSystem[_]): EventSourcedBehavior[Command, Event, State] =
|
||||
counter(ctx, persistenceId, TestProbe[String]().ref, probe, TestProbe[Try[SnapshotMetadata]]().ref)
|
||||
|
||||
def counterWithSnapshotProbe(
|
||||
ctx: ActorContext[Command],
|
||||
persistenceId: PersistenceId,
|
||||
probe: ActorRef[Try[SnapshotMetadata]])(
|
||||
implicit system: ActorSystem[_]): EventSourcedBehavior[Command, Event, State] =
|
||||
counter(ctx, persistenceId, TestProbe[String]().ref, TestProbe[(State, Event)]().ref, snapshotProbe = probe)
|
||||
|
||||
def counter(
|
||||
ctx: ActorContext[Command],
|
||||
persistenceId: PersistenceId,
|
||||
loggingActor: ActorRef[String],
|
||||
probe: ActorRef[(State, Event)],
|
||||
snapshotProbe: ActorRef[Try[SnapshotMetadata]]): EventSourcedBehavior[Command, Event, State] = {
|
||||
EventSourcedBehavior[Command, Event, State](
|
||||
persistenceId,
|
||||
emptyState = State(0, Vector.empty),
|
||||
commandHandler = (state, cmd) =>
|
||||
cmd match {
|
||||
case Increment =>
|
||||
Effect.persist(Incremented(1))
|
||||
|
||||
case IncrementThenLogThenStop =>
|
||||
Effect
|
||||
.persist(Incremented(1))
|
||||
.thenRun { (_: State) =>
|
||||
loggingActor ! firstLogging
|
||||
}
|
||||
.thenStop()
|
||||
|
||||
case IncrementTwiceThenLogThenStop =>
|
||||
Effect
|
||||
.persist(Incremented(1), Incremented(2))
|
||||
.thenRun { (_: State) =>
|
||||
loggingActor ! firstLogging
|
||||
}
|
||||
.thenStop()
|
||||
|
||||
case IncrementWithPersistAll(n) =>
|
||||
Effect.persist((0 until n).map(_ => Incremented(1)))
|
||||
|
||||
case IncrementWithConfirmation(replyTo) =>
|
||||
Effect.persist(Incremented(1)).thenReply(replyTo)(_ => Done)
|
||||
|
||||
case GetValue(replyTo) =>
|
||||
replyTo ! state
|
||||
Effect.none
|
||||
|
||||
case IncrementLater =>
|
||||
// purpose is to test signals
|
||||
val delay = ctx.spawnAnonymous(Behaviors.withTimers[Tick.type] { timers =>
|
||||
timers.startSingleTimer(Tick, 10.millis)
|
||||
Behaviors.receive((_, msg) =>
|
||||
msg match {
|
||||
case Tick => Behaviors.stopped
|
||||
})
|
||||
})
|
||||
ctx.watchWith(delay, DelayFinished)
|
||||
Effect.none
|
||||
|
||||
case DelayFinished =>
|
||||
Effect.persist(Incremented(10))
|
||||
|
||||
case IncrementAfterReceiveTimeout =>
|
||||
ctx.setReceiveTimeout(10.millis, Timeout)
|
||||
Effect.none
|
||||
|
||||
case Timeout =>
|
||||
ctx.cancelReceiveTimeout()
|
||||
Effect.persist(Incremented(100))
|
||||
|
||||
case IncrementTwiceAndThenLog =>
|
||||
Effect
|
||||
.persist(Incremented(1), Incremented(1))
|
||||
.thenRun { (_: State) =>
|
||||
loggingActor ! firstLogging
|
||||
}
|
||||
.thenRun { _ =>
|
||||
loggingActor ! secondLogging
|
||||
}
|
||||
|
||||
case EmptyEventsListAndThenLog =>
|
||||
Effect
|
||||
.persist(List.empty) // send empty list of events
|
||||
.thenRun { _ =>
|
||||
loggingActor ! firstLogging
|
||||
}
|
||||
|
||||
case DoNothingAndThenLog =>
|
||||
Effect.none.thenRun { _ =>
|
||||
loggingActor ! firstLogging
|
||||
}
|
||||
|
||||
case LogThenStop =>
|
||||
Effect
|
||||
.none[Event, State]
|
||||
.thenRun { _ =>
|
||||
loggingActor ! firstLogging
|
||||
}
|
||||
.thenStop()
|
||||
|
||||
case Fail =>
|
||||
throw new TestException("boom!")
|
||||
|
||||
case StopIt =>
|
||||
Effect.none.thenStop()
|
||||
|
||||
},
|
||||
eventHandler = (state, evt) =>
|
||||
evt match {
|
||||
case Incremented(delta) =>
|
||||
probe ! ((state, evt))
|
||||
State(state.value + delta, state.history :+ state.value)
|
||||
}).receiveSignal {
|
||||
case (_, RecoveryCompleted) => ()
|
||||
case (_, SnapshotCompleted(metadata)) =>
|
||||
snapshotProbe ! Success(metadata)
|
||||
case (_, SnapshotFailed(_, failure)) =>
|
||||
snapshotProbe ! Failure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedBehaviorSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorSpec._
|
||||
|
||||
val queries: PersistenceTestKitReadJournal =
|
||||
PersistenceQuery(system).readJournalFor[PersistenceTestKitReadJournal](PersistenceTestKitReadJournal.Identifier)
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"A typed persistent actor" must {
|
||||
|
||||
"persist an event" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[State]()
|
||||
c ! Increment
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(1, Vector(0)))
|
||||
}
|
||||
|
||||
"replay stored events" in {
|
||||
val pid = nextPid()
|
||||
val c = spawn(counter(pid))
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
c ! Increment
|
||||
c ! Increment
|
||||
c ! Increment
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(10.seconds, State(3, Vector(0, 1, 2)))
|
||||
|
||||
val c2 = spawn(counter(pid))
|
||||
c2 ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
c2 ! Increment
|
||||
c2 ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(4, Vector(0, 1, 2, 3)))
|
||||
}
|
||||
|
||||
"handle Terminated signal" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[State]()
|
||||
c ! Increment
|
||||
c ! IncrementLater
|
||||
eventually {
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(11, Vector(0, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
"handle receive timeout" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
c ! Increment
|
||||
c ! IncrementAfterReceiveTimeout
|
||||
// let it timeout
|
||||
Thread.sleep(500)
|
||||
eventually {
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(101, Vector(0, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
"adhere default and disabled Recovery strategies" in {
|
||||
val pid = nextPid()
|
||||
val probe = TestProbe[State]()
|
||||
|
||||
def counterWithRecoveryStrategy(recoveryStrategy: Recovery) =
|
||||
Behaviors.setup[Command](counter(_, pid).withRecovery(recoveryStrategy))
|
||||
|
||||
val counterSetup = spawn(counterWithRecoveryStrategy(Recovery.default))
|
||||
counterSetup ! Increment
|
||||
counterSetup ! Increment
|
||||
counterSetup ! Increment
|
||||
counterSetup ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
counterSetup ! Increment
|
||||
counterSetup ! StopIt
|
||||
probe.expectTerminated(counterSetup)
|
||||
|
||||
val counterDefaultRecoveryStrategy = spawn(counterWithRecoveryStrategy(Recovery.default))
|
||||
counterDefaultRecoveryStrategy ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(4, Vector(0, 1, 2, 3)))
|
||||
counterDefaultRecoveryStrategy ! StopIt
|
||||
probe.expectTerminated(counterDefaultRecoveryStrategy)
|
||||
|
||||
val counterDisabledRecoveryStrategy = spawn(counterWithRecoveryStrategy(Recovery.disabled))
|
||||
counterDisabledRecoveryStrategy ! Increment
|
||||
counterDisabledRecoveryStrategy ! Increment
|
||||
counterDisabledRecoveryStrategy ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(2, Vector(0, 1)))
|
||||
}
|
||||
|
||||
"adhere Recovery strategy with SnapshotSelectionCriteria" in {
|
||||
val pid = nextPid()
|
||||
val eventProbe = TestProbe[(State, Event)]()
|
||||
val commandProbe = TestProbe[State]()
|
||||
val snapshotProbe = TestProbe[Try[SnapshotMetadata]]()
|
||||
|
||||
def counterWithSnapshotSelectionCriteria(recoveryStrategy: Recovery) =
|
||||
Behaviors.setup[Command](
|
||||
counterWithProbe(_, pid, eventProbe.ref, snapshotProbe.ref).withRecovery(recoveryStrategy).snapshotWhen {
|
||||
case (_, _, _) => true
|
||||
})
|
||||
|
||||
val counterSetup = spawn(counterWithSnapshotSelectionCriteria(Recovery.default))
|
||||
counterSetup ! Increment
|
||||
counterSetup ! Increment
|
||||
counterSetup ! Increment
|
||||
eventProbe.receiveMessages(3)
|
||||
snapshotProbe.receiveMessages(3)
|
||||
counterSetup ! GetValue(commandProbe.ref)
|
||||
commandProbe.expectMessage(State(3, Vector(0, 1, 2)))
|
||||
counterSetup ! StopIt
|
||||
commandProbe.expectTerminated(counterSetup)
|
||||
|
||||
val counterWithSnapshotSelectionCriteriaNone = spawn(
|
||||
counterWithSnapshotSelectionCriteria(Recovery.withSnapshotSelectionCriteria(SnapshotSelectionCriteria.none)))
|
||||
// replay all events, no snapshot
|
||||
eventProbe.expectMessage(State(0, Vector.empty) -> Incremented(1))
|
||||
eventProbe.expectMessage(State(1, Vector(0)) -> Incremented(1))
|
||||
eventProbe.expectMessage(State(2, Vector(0, 1)) -> Incremented(1))
|
||||
counterWithSnapshotSelectionCriteriaNone ! Increment
|
||||
eventProbe.expectMessage(State(3, Vector(0, 1, 2)) -> Incremented(1))
|
||||
counterWithSnapshotSelectionCriteriaNone ! GetValue(commandProbe.ref)
|
||||
commandProbe.expectMessage(State(4, Vector(0, 1, 2, 3)))
|
||||
|
||||
counterWithSnapshotSelectionCriteriaNone ! StopIt
|
||||
commandProbe.expectTerminated(counterWithSnapshotSelectionCriteriaNone)
|
||||
|
||||
val counterWithSnapshotSelectionCriteriaLatest = spawn(
|
||||
counterWithSnapshotSelectionCriteria(Recovery.withSnapshotSelectionCriteria(SnapshotSelectionCriteria.latest)))
|
||||
// replay no events, only latest snapshot
|
||||
eventProbe.expectNoMessage()
|
||||
counterWithSnapshotSelectionCriteriaLatest ! Increment
|
||||
counterWithSnapshotSelectionCriteriaLatest ! GetValue(commandProbe.ref)
|
||||
commandProbe.expectMessage(State(5, Vector(0, 1, 2, 3, 4)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that all side-effects callbacks are called (in order) and only once.
|
||||
* The [[IncrementTwiceAndThenLog]] command will emit two Increment events
|
||||
*/
|
||||
"chainable side effects with events" in {
|
||||
val loggingProbe = TestProbe[String]()
|
||||
val c = spawn(counter(nextPid(), loggingProbe.ref))
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
|
||||
c ! IncrementTwiceAndThenLog
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(2, Vector(0, 1)))
|
||||
|
||||
loggingProbe.expectMessage(firstLogging)
|
||||
loggingProbe.expectMessage(secondLogging)
|
||||
}
|
||||
|
||||
"persist then stop" in {
|
||||
val loggingProbe = TestProbe[String]()
|
||||
val c = spawn(counter(nextPid(), loggingProbe.ref))
|
||||
val watchProbe = watcher(c)
|
||||
|
||||
c ! IncrementThenLogThenStop
|
||||
loggingProbe.expectMessage(firstLogging)
|
||||
watchProbe.expectMessage("Terminated")
|
||||
}
|
||||
|
||||
"persist(All) then stop" in {
|
||||
val loggingProbe = TestProbe[String]()
|
||||
val c = spawn(counter(nextPid(), loggingProbe.ref))
|
||||
val watchProbe = watcher(c)
|
||||
|
||||
c ! IncrementTwiceThenLogThenStop
|
||||
loggingProbe.expectMessage(firstLogging)
|
||||
watchProbe.expectMessage("Terminated")
|
||||
|
||||
}
|
||||
|
||||
"persist an event thenReply" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[Done]()
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
probe.expectMessage(Done)
|
||||
}
|
||||
|
||||
/** Proves that side-effects are called when emitting an empty list of events */
|
||||
"chainable side effects without events" in {
|
||||
val loggingProbe = TestProbe[String]()
|
||||
val c = spawn(counter(nextPid(), loggingProbe.ref))
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
c ! EmptyEventsListAndThenLog
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(0, Vector.empty))
|
||||
loggingProbe.expectMessage(firstLogging)
|
||||
}
|
||||
|
||||
/** Proves that side-effects are called when explicitly calling Effect.none */
|
||||
"chainable side effects when doing nothing (Effect.none)" in {
|
||||
val loggingProbe = TestProbe[String]()
|
||||
val c = spawn(counter(nextPid(), loggingProbe.ref))
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
c ! DoNothingAndThenLog
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(0, Vector.empty))
|
||||
loggingProbe.expectMessage(firstLogging)
|
||||
}
|
||||
|
||||
"work when wrapped in other behavior" in {
|
||||
val probe = TestProbe[State]()
|
||||
val behavior = Behaviors
|
||||
.supervise[Command](counter(nextPid()))
|
||||
.onFailure(SupervisorStrategy.restartWithBackoff(1.second, 10.seconds, 0.1))
|
||||
val c = spawn(behavior)
|
||||
c ! Increment
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(1, Vector(0)))
|
||||
}
|
||||
|
||||
"stop after logging (no persisting)" in {
|
||||
val loggingProbe = TestProbe[String]()
|
||||
val c: ActorRef[Command] = spawn(counter(nextPid(), loggingProbe.ref))
|
||||
val watchProbe = watcher(c)
|
||||
c ! LogThenStop
|
||||
loggingProbe.expectMessage(firstLogging)
|
||||
watchProbe.expectMessage("Terminated")
|
||||
}
|
||||
|
||||
"wrap persistent behavior in tap" in {
|
||||
val probe = TestProbe[Command]()
|
||||
val wrapped: Behavior[Command] = Behaviors.monitor(probe.ref, counter(nextPid()))
|
||||
val c = spawn(wrapped)
|
||||
|
||||
c ! Increment
|
||||
val replyProbe = TestProbe[State]()
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
probe.expectMessage(Increment)
|
||||
}
|
||||
|
||||
"tag events" in {
|
||||
val pid = nextPid()
|
||||
val c = spawn(Behaviors.setup[Command](ctx => counter(ctx, pid).withTagger(_ => Set("tag1", "tag2"))))
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
|
||||
val events = queries.currentEventsByTag("tag1", Offset.noOffset).runWith(Sink.seq).futureValue
|
||||
events shouldEqual List(EventEnvelope(Sequence(1), pid.id, 1, Incremented(1), 0L))
|
||||
}
|
||||
|
||||
"handle scheduled message arriving before recovery completed " in {
|
||||
val c = spawn(Behaviors.withTimers[Command] { timers =>
|
||||
timers.startSingleTimer(Increment, 1.millis)
|
||||
Thread.sleep(30) // now it's probably already in the mailbox, and will be stashed
|
||||
counter(nextPid())
|
||||
})
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
c ! Increment
|
||||
probe.awaitAssert {
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(2, Vector(0, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
"handle scheduled message arriving after recovery completed " in {
|
||||
val c = spawn(Behaviors.withTimers[Command] { timers =>
|
||||
// probably arrives after recovery completed
|
||||
timers.startSingleTimer(Increment, 200.millis)
|
||||
counter(nextPid())
|
||||
})
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
c ! Increment
|
||||
probe.awaitAssert {
|
||||
c ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(2, Vector(0, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
"fail after recovery timeout" in {
|
||||
LoggingTestKit.error("Exception during recovery from snapshot").expect {
|
||||
val c = spawn(
|
||||
Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, nextPid())
|
||||
.withSnapshotPluginId("slow-snapshot-store")
|
||||
.withJournalPluginId("short-recovery-timeout")))
|
||||
|
||||
val probe = TestProbe[State]()
|
||||
|
||||
probe.expectTerminated(c, probe.remainingOrDefault)
|
||||
}
|
||||
}
|
||||
|
||||
"not wrap a failure caused by command stashed while recovering in a journal failure" in {
|
||||
val pid = nextPid()
|
||||
val probe = TestProbe[AnyRef]()
|
||||
|
||||
// put some events in there, so that recovering takes a little time
|
||||
val c = spawn(Behaviors.setup[Command](counter(_, pid)))
|
||||
(0 to 50).foreach { _ =>
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
}
|
||||
c ! StopIt
|
||||
probe.expectTerminated(c)
|
||||
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
val c2 = spawn(Behaviors.setup[Command](counter(_, pid)))
|
||||
c2 ! Fail
|
||||
probe.expectTerminated(c2) // should fail
|
||||
}
|
||||
}
|
||||
|
||||
"fail fast if persistenceId is null" in {
|
||||
intercept[IllegalArgumentException] {
|
||||
PersistenceId.ofUniqueId(null)
|
||||
}
|
||||
val probe = TestProbe[AnyRef]()
|
||||
LoggingTestKit.error[ActorInitializationException].withMessageContains("persistenceId must not be null").expect {
|
||||
val ref = spawn(Behaviors.setup[Command](counter(_, persistenceId = PersistenceId.ofUniqueId(null))))
|
||||
probe.expectTerminated(ref)
|
||||
}
|
||||
LoggingTestKit.error[ActorInitializationException].withMessageContains("persistenceId must not be null").expect {
|
||||
val ref = spawn(Behaviors.setup[Command](counter(_, persistenceId = null)))
|
||||
probe.expectTerminated(ref)
|
||||
}
|
||||
}
|
||||
|
||||
"fail fast if persistenceId is empty" in {
|
||||
intercept[IllegalArgumentException] {
|
||||
PersistenceId.ofUniqueId("")
|
||||
}
|
||||
val probe = TestProbe[AnyRef]()
|
||||
LoggingTestKit.error[ActorInitializationException].withMessageContains("persistenceId must not be empty").expect {
|
||||
val ref = spawn(Behaviors.setup[Command](counter(_, persistenceId = PersistenceId.ofUniqueId(""))))
|
||||
probe.expectTerminated(ref)
|
||||
}
|
||||
}
|
||||
|
||||
"fail fast if default journal plugin is not defined" in {
|
||||
// new ActorSystem without persistence config
|
||||
val testkit2 = ActorTestKit(ActorTestKitBase.testNameFromCallStack(), ConfigFactory.parseString(""))
|
||||
try {
|
||||
LoggingTestKit
|
||||
.error[ActorInitializationException]
|
||||
.withMessageContains("Default journal plugin is not configured")
|
||||
.expect {
|
||||
val ref = testkit2.spawn(Behaviors.setup[Command](counter(_, nextPid())))
|
||||
val probe = testkit2.createTestProbe()
|
||||
probe.expectTerminated(ref)
|
||||
}(testkit2.system)
|
||||
} finally {
|
||||
testkit2.shutdownTestKit()
|
||||
}
|
||||
}
|
||||
|
||||
"fail fast if given journal plugin is not defined" in {
|
||||
// new ActorSystem without persistence config
|
||||
val testkit2 = ActorTestKit(ActorTestKitBase.testNameFromCallStack(), ConfigFactory.parseString(""))
|
||||
try {
|
||||
LoggingTestKit
|
||||
.error[ActorInitializationException]
|
||||
.withMessageContains("Journal plugin [missing] configuration doesn't exist")
|
||||
.expect {
|
||||
val ref = testkit2.spawn(Behaviors.setup[Command](counter(_, nextPid()).withJournalPluginId("missing")))
|
||||
val probe = testkit2.createTestProbe()
|
||||
probe.expectTerminated(ref)
|
||||
}(testkit2.system)
|
||||
} finally {
|
||||
testkit2.shutdownTestKit()
|
||||
}
|
||||
}
|
||||
|
||||
"warn if default snapshot plugin is not defined" in {
|
||||
// new ActorSystem without snapshot plugin config
|
||||
val testkit2 = ActorTestKit(
|
||||
ActorTestKitBase.testNameFromCallStack(),
|
||||
ConfigFactory.parseString(s"""
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
"""))
|
||||
try {
|
||||
LoggingTestKit
|
||||
.warn("No default snapshot store configured")
|
||||
.expect {
|
||||
val ref = testkit2.spawn(Behaviors.setup[Command](counter(_, nextPid())))
|
||||
val probe = testkit2.createTestProbe[State]()
|
||||
// verify that it's not terminated
|
||||
ref ! GetValue(probe.ref)
|
||||
probe.expectMessage(State(0, Vector.empty))
|
||||
}(testkit2.system)
|
||||
} finally {
|
||||
testkit2.shutdownTestKit()
|
||||
}
|
||||
}
|
||||
|
||||
"fail fast if given snapshot plugin is not defined" in {
|
||||
// new ActorSystem without snapshot plugin config
|
||||
val testkit2 = ActorTestKit(
|
||||
ActorTestKitBase.testNameFromCallStack(),
|
||||
ConfigFactory.parseString(s"""
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
"""))
|
||||
try {
|
||||
LoggingTestKit
|
||||
.error[ActorInitializationException]
|
||||
.withMessageContains("Snapshot store plugin [missing] configuration doesn't exist")
|
||||
.expect {
|
||||
val ref = testkit2.spawn(Behaviors.setup[Command](counter(_, nextPid()).withSnapshotPluginId("missing")))
|
||||
val probe = testkit2.createTestProbe()
|
||||
probe.expectTerminated(ref)
|
||||
}(testkit2.system)
|
||||
} finally {
|
||||
testkit2.shutdownTestKit()
|
||||
}
|
||||
}
|
||||
|
||||
"allow enumerating all ids" in {
|
||||
val all = queries.currentPersistenceIds(None, Long.MaxValue).runWith(Sink.seq).futureValue
|
||||
all.size should be > 5
|
||||
|
||||
val firstThree = queries.currentPersistenceIds(None, 3).runWith(Sink.seq).futureValue
|
||||
firstThree.size shouldBe 3
|
||||
val others = queries.currentPersistenceIds(Some(firstThree.last), Long.MaxValue).runWith(Sink.seq).futureValue
|
||||
|
||||
firstThree ++ others should contain theSameElementsInOrderAs all
|
||||
}
|
||||
|
||||
def watcher(toWatch: ActorRef[_]): TestProbe[String] = {
|
||||
val probe = TestProbe[String]()
|
||||
val w = Behaviors.setup[Any] { ctx =>
|
||||
ctx.watch(toWatch)
|
||||
Behaviors
|
||||
.receive[Any] { (_, _) =>
|
||||
Behaviors.same
|
||||
}
|
||||
.receiveSignal {
|
||||
case (_, _: Terminated) =>
|
||||
probe.ref ! "Terminated"
|
||||
Behaviors.stopped
|
||||
}
|
||||
}
|
||||
spawn(w)
|
||||
probe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,736 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.NotUsed
|
||||
import pekko.actor.Dropped
|
||||
import pekko.actor.UnhandledMessage
|
||||
import pekko.actor.testkit.typed.TestException
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.PostStop
|
||||
import pekko.actor.typed.SupervisorStrategy
|
||||
import pekko.actor.typed.eventstream.EventStream
|
||||
import pekko.actor.typed.internal.PoisonPill
|
||||
import pekko.actor.typed.javadsl.StashOverflowException
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.actor.typed.scaladsl.adapter._
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import scala.concurrent.duration._
|
||||
|
||||
object EventSourcedBehaviorStashSpec {
|
||||
def conf: Config = ConfigFactory.parseString(s"""
|
||||
#pekko.loglevel = DEBUG
|
||||
#pekko.persistence.typed.log-stashing = on
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
pekko.persistence.journal.plugin = "failure-journal"
|
||||
# tune it down a bit so we can hit limit
|
||||
pekko.persistence.typed.stash-capacity = 500
|
||||
failure-journal = $${pekko.persistence.journal.inmem}
|
||||
failure-journal {
|
||||
class = "org.apache.pekko.persistence.typed.scaladsl.ChaosJournal"
|
||||
}
|
||||
""").withFallback(ConfigFactory.defaultReference()).resolve()
|
||||
|
||||
sealed trait Command[ReplyMessage]
|
||||
// Unstash and change to active mode
|
||||
final case class Activate(id: String, val replyTo: ActorRef[Ack]) extends Command[Ack]
|
||||
// Change to active mode, stash incoming Increment
|
||||
final case class Deactivate(id: String, val replyTo: ActorRef[Ack]) extends Command[Ack]
|
||||
// Persist Incremented if in active mode, otherwise stashed
|
||||
final case class Increment(id: String, val replyTo: ActorRef[Ack]) extends Command[Ack]
|
||||
// Persist ValueUpdated, independent of active/inactive
|
||||
final case class UpdateValue(id: String, value: Int, val replyTo: ActorRef[Ack]) extends Command[Ack]
|
||||
// Retrieve current state, independent of active/inactive
|
||||
final case class GetValue(replyTo: ActorRef[State]) extends Command[State]
|
||||
final case class Unhandled(replyTo: ActorRef[NotUsed]) extends Command[NotUsed]
|
||||
final case class Throw(id: String, t: Throwable, val replyTo: ActorRef[Ack]) extends Command[Ack]
|
||||
final case class IncrementThenThrow(id: String, t: Throwable) extends Command[Ack]
|
||||
final case class Slow(id: String, latch: CountDownLatch, val replyTo: ActorRef[Ack]) extends Command[Ack]
|
||||
|
||||
final case class Ack(id: String)
|
||||
|
||||
sealed trait Event
|
||||
final case class Incremented(delta: Int) extends Event
|
||||
final case class ValueUpdated(value: Int) extends Event
|
||||
case object Activated extends Event
|
||||
case object Deactivated extends Event
|
||||
|
||||
final case class State(value: Int, active: Boolean)
|
||||
|
||||
def counter(persistenceId: PersistenceId, signalProbe: Option[ActorRef[String]] = None): Behavior[Command[_]] =
|
||||
Behaviors
|
||||
.supervise[Command[_]] {
|
||||
Behaviors.setup(_ => eventSourcedCounter(persistenceId, signalProbe))
|
||||
}
|
||||
.onFailure(SupervisorStrategy.restart.withLoggingEnabled(enabled = false))
|
||||
|
||||
def eventSourcedCounter(
|
||||
persistenceId: PersistenceId,
|
||||
signalProbe: Option[ActorRef[String]]): EventSourcedBehavior[Command[_], Event, State] = {
|
||||
EventSourcedBehavior
|
||||
.withEnforcedReplies[Command[_], Event, State](
|
||||
persistenceId,
|
||||
emptyState = State(0, active = true),
|
||||
commandHandler = (state, command) => {
|
||||
if (state.active) active(state, command)
|
||||
else inactive(state, command)
|
||||
},
|
||||
eventHandler = (state, evt) =>
|
||||
evt match {
|
||||
case Incremented(delta) =>
|
||||
if (!state.active) throw new IllegalStateException
|
||||
State(state.value + delta, active = true)
|
||||
case ValueUpdated(value) =>
|
||||
State(value, active = state.active)
|
||||
case Activated =>
|
||||
if (state.active) throw new IllegalStateException
|
||||
state.copy(active = true)
|
||||
case Deactivated =>
|
||||
if (!state.active) throw new IllegalStateException
|
||||
state.copy(active = false)
|
||||
})
|
||||
.onPersistFailure(SupervisorStrategy
|
||||
.restartWithBackoff(1.second, maxBackoff = 2.seconds, 0.0)
|
||||
.withLoggingEnabled(enabled = false))
|
||||
.receiveSignal {
|
||||
case (state, RecoveryCompleted) => signalProbe.foreach(_ ! s"RecoveryCompleted-${state.value}")
|
||||
case (_, PostStop) => signalProbe.foreach(_ ! "PostStop")
|
||||
}
|
||||
}
|
||||
|
||||
private def active(state: State, command: Command[_]): ReplyEffect[Event, State] = {
|
||||
command match {
|
||||
case Increment(id, replyTo) =>
|
||||
Effect.persist(Incremented(1)).thenReply(replyTo)(_ => Ack(id))
|
||||
case UpdateValue(id, value, replyTo) =>
|
||||
Effect.persist(ValueUpdated(value)).thenReply(replyTo)(_ => Ack(id))
|
||||
case GetValue(replyTo) =>
|
||||
Effect.reply(replyTo)(state)
|
||||
case Deactivate(id, replyTo) =>
|
||||
Effect.persist(Deactivated).thenReply(replyTo)(_ => Ack(id))
|
||||
case Activate(id, replyTo) =>
|
||||
// already active
|
||||
Effect.reply(replyTo)(Ack(id))
|
||||
case _: Unhandled =>
|
||||
Effect.unhandled.thenNoReply()
|
||||
case Throw(id, t, replyTo) =>
|
||||
replyTo ! Ack(id)
|
||||
throw t
|
||||
case IncrementThenThrow(_, throwable) =>
|
||||
Effect.persist(Incremented(1)).thenRun((_: State) => throw throwable).thenNoReply()
|
||||
case Slow(id, latch, replyTo) =>
|
||||
latch.await(30, TimeUnit.SECONDS)
|
||||
Effect.reply(replyTo)(Ack(id))
|
||||
}
|
||||
}
|
||||
|
||||
private def inactive(state: State, command: Command[_]): ReplyEffect[Event, State] = {
|
||||
command match {
|
||||
case _: Increment =>
|
||||
Effect.stash()
|
||||
case UpdateValue(id, value, replyTo) =>
|
||||
Effect.persist(ValueUpdated(value)).thenReply(replyTo)(_ => Ack(id))
|
||||
case GetValue(replyTo) =>
|
||||
Effect.reply(replyTo)(state)
|
||||
case Deactivate(id, replyTo) =>
|
||||
// already inactive
|
||||
Effect.reply(replyTo)(Ack(id))
|
||||
case Activate(id, replyTo) =>
|
||||
Effect.persist(Activated).thenReply(replyTo)((_: State) => Ack(id)).thenUnstashAll()
|
||||
case _: Unhandled =>
|
||||
Effect.unhandled.thenNoReply()
|
||||
case Throw(id, t, replyTo) =>
|
||||
replyTo ! Ack(id)
|
||||
throw t
|
||||
case _: IncrementThenThrow =>
|
||||
Effect.stash()
|
||||
case _: Slow =>
|
||||
Effect.stash()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorStashSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedBehaviorStashSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorStashSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"A typed persistent actor that is stashing commands" must {
|
||||
|
||||
"stash and unstash" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment("1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("1"))
|
||||
|
||||
c ! Deactivate("2", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("2"))
|
||||
|
||||
c ! Increment("3", ackProbe.ref)
|
||||
c ! Increment("4", ackProbe.ref)
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(1, active = false))
|
||||
|
||||
c ! Activate("5", ackProbe.ref)
|
||||
c ! Increment("6", ackProbe.ref)
|
||||
|
||||
ackProbe.expectMessage(Ack("5"))
|
||||
ackProbe.expectMessage(Ack("3"))
|
||||
ackProbe.expectMessage(Ack("4"))
|
||||
ackProbe.expectMessage(Ack("6"))
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(4, active = true))
|
||||
}
|
||||
|
||||
"handle mix of stash, persist and unstash" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment("1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("1"))
|
||||
|
||||
c ! Deactivate("2", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("2"))
|
||||
|
||||
c ! Increment("3", ackProbe.ref)
|
||||
c ! Increment("4", ackProbe.ref)
|
||||
// UpdateValue will persist when inactive (with previously stashed commands)
|
||||
c ! UpdateValue("5", 100, ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("5"))
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(100, active = false))
|
||||
|
||||
c ! Activate("6", ackProbe.ref)
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(102, active = true))
|
||||
ackProbe.expectMessage(Ack("6"))
|
||||
ackProbe.expectMessage(Ack("3"))
|
||||
ackProbe.expectMessage(Ack("4"))
|
||||
}
|
||||
|
||||
"unstash in right order" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment(s"inc-1", ackProbe.ref)
|
||||
|
||||
c ! Deactivate(s"deact", ackProbe.ref)
|
||||
|
||||
c ! Increment(s"inc-2", ackProbe.ref)
|
||||
c ! Increment(s"inc-3", ackProbe.ref)
|
||||
c ! Increment(s"inc-4", ackProbe.ref)
|
||||
|
||||
c ! Activate(s"act", ackProbe.ref)
|
||||
|
||||
c ! Increment(s"inc-5", ackProbe.ref)
|
||||
c ! Increment(s"inc-6", ackProbe.ref)
|
||||
c ! Increment(s"inc-7", ackProbe.ref)
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
val finalState = stateProbe.expectMessageType[State](5.seconds)
|
||||
|
||||
// verify the order
|
||||
|
||||
ackProbe.expectMessage(Ack("inc-1"))
|
||||
ackProbe.expectMessage(Ack("deact"))
|
||||
ackProbe.expectMessage(Ack("act"))
|
||||
ackProbe.expectMessage(Ack("inc-2"))
|
||||
ackProbe.expectMessage(Ack("inc-3"))
|
||||
ackProbe.expectMessage(Ack("inc-4"))
|
||||
ackProbe.expectMessage(Ack("inc-5"))
|
||||
ackProbe.expectMessage(Ack("inc-6"))
|
||||
ackProbe.expectMessage(Ack("inc-7"))
|
||||
|
||||
finalState.value should ===(7)
|
||||
}
|
||||
|
||||
"handle many stashed" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
val notUsedProbe = TestProbe[NotUsed]()
|
||||
val unhandledProbe = createTestProbe[UnhandledMessage]()
|
||||
system.eventStream ! EventStream.Subscribe(unhandledProbe.ref)
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
c ! Increment(s"inc-1-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 3).foreach { n =>
|
||||
c ! Deactivate(s"deact-2-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
if (n % 10 == 0)
|
||||
c ! Unhandled(notUsedProbe.ref)
|
||||
c ! Increment(s"inc-3-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
|
||||
(1 to 5).foreach { n =>
|
||||
c ! UpdateValue(s"upd-4-$n", n * 1000, ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 3).foreach { n =>
|
||||
c ! Activate(s"act-5-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
c ! Increment(s"inc-6-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
|
||||
(6 to 8).foreach { n =>
|
||||
c ! UpdateValue(s"upd-7-$n", n * 1000, ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 3).foreach { n =>
|
||||
c ! Deactivate(s"deact-8-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
c ! Increment(s"inc-9-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 3).foreach { n =>
|
||||
c ! Activate(s"act-10-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
|
||||
unhandledProbe.receiveMessages(10)
|
||||
|
||||
val value1 = stateProbe.expectMessageType[State](5.seconds).value
|
||||
val value2 = stateProbe.expectMessageType[State](5.seconds).value
|
||||
val value3 = stateProbe.expectMessageType[State](5.seconds).value
|
||||
|
||||
// verify the order
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"inc-1-$n"))
|
||||
}
|
||||
|
||||
(1 to 3).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"deact-2-$n"))
|
||||
}
|
||||
|
||||
(1 to 5).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"upd-4-$n"))
|
||||
}
|
||||
|
||||
ackProbe.expectMessage(Ack("act-5-1"))
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"inc-3-$n"))
|
||||
}
|
||||
|
||||
(2 to 3).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"act-5-$n"))
|
||||
}
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"inc-6-$n"))
|
||||
}
|
||||
|
||||
(6 to 8).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"upd-7-$n"))
|
||||
}
|
||||
|
||||
(1 to 3).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"deact-8-$n"))
|
||||
}
|
||||
|
||||
ackProbe.expectMessage(Ack("act-10-1"))
|
||||
|
||||
(1 to 100).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"inc-9-$n"))
|
||||
}
|
||||
|
||||
(2 to 3).foreach { n =>
|
||||
ackProbe.expectMessage(Ack(s"act-10-$n"))
|
||||
}
|
||||
|
||||
value1 should ===(100)
|
||||
value2 should ===(5200)
|
||||
value3 should ===(8100)
|
||||
}
|
||||
|
||||
"discard user stash when restarted due to thrown exception" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment("inc-1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("inc-1"))
|
||||
|
||||
c ! Deactivate("deact", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("deact"))
|
||||
|
||||
c ! Increment("inc-2", ackProbe.ref)
|
||||
c ! Increment("inc-3", ackProbe.ref)
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(1, active = false))
|
||||
|
||||
c ! Throw("throw", new TestException("test"), ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("throw"))
|
||||
|
||||
c ! Increment("inc-4", ackProbe.ref)
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(1, active = false))
|
||||
|
||||
c ! Activate("act", ackProbe.ref)
|
||||
c ! Increment("inc-5", ackProbe.ref)
|
||||
|
||||
ackProbe.expectMessage(Ack("act"))
|
||||
// inc-2 an inc-3 was in user stash, and didn't survive restart, as expected
|
||||
ackProbe.expectMessage(Ack("inc-4"))
|
||||
ackProbe.expectMessage(Ack("inc-5"))
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(3, active = true))
|
||||
}
|
||||
|
||||
"discard internal stash when restarted due to thrown exception" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
val latch = new CountDownLatch(1)
|
||||
|
||||
// make first command slow to ensure that all subsequent commands are enqueued first
|
||||
c ! Slow("slow", latch, ackProbe.ref)
|
||||
|
||||
(1 to 10).foreach { n =>
|
||||
if (n == 3)
|
||||
c ! IncrementThenThrow(s"inc-$n", new TestException("test"))
|
||||
else
|
||||
c ! Increment(s"inc-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
latch.countDown()
|
||||
ackProbe.expectMessage(Ack("slow"))
|
||||
|
||||
ackProbe.expectMessage(Ack("inc-1"))
|
||||
ackProbe.expectMessage(Ack("inc-2"))
|
||||
ackProbe.expectNoMessage()
|
||||
|
||||
c ! Increment("inc-11", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("inc-11"))
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(4, active = true))
|
||||
}
|
||||
|
||||
"preserve internal stash when persist failed" in {
|
||||
val c = spawn(counter(PersistenceId.ofUniqueId("fail-fifth-a")))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
|
||||
(1 to 10).foreach { n =>
|
||||
c ! Increment(s"inc-$n", ackProbe.ref)
|
||||
}
|
||||
|
||||
(1 to 10).foreach { n =>
|
||||
if (n != 5)
|
||||
ackProbe.expectMessage(Ack(s"inc-$n"))
|
||||
}
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(9, active = true))
|
||||
}
|
||||
|
||||
"unstash messages after stashed GetState" in {
|
||||
import pekko.persistence.typed.internal.EventSourcedBehaviorImpl.{ GetState, GetStateReply }
|
||||
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
val getStateProbe = TestProbe[GetStateReply[State]]()
|
||||
val latch = new CountDownLatch(1)
|
||||
c ! Increment(s"inc-1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack(s"inc-1"))
|
||||
// make sure all the following messages are already in the mailbox
|
||||
c ! Slow("slow", latch, ackProbe.ref)
|
||||
// this will persist an event
|
||||
c ! Increment(s"inc-2", ackProbe.ref)
|
||||
// while the next two messages are already in the mailbox, so they will be stashed in the persisting state
|
||||
c.unsafeUpcast[Any] ! GetState(getStateProbe.ref)
|
||||
c ! Increment(s"inc-3", ackProbe.ref)
|
||||
c ! Increment(s"inc-4", ackProbe.ref)
|
||||
|
||||
latch.countDown()
|
||||
|
||||
ackProbe.expectMessage(Ack("slow"))
|
||||
ackProbe.expectMessage(Ack(s"inc-2"))
|
||||
getStateProbe.expectMessageType[GetStateReply[State]]
|
||||
ackProbe.expectMessage(Ack(s"inc-3"))
|
||||
ackProbe.expectMessage(Ack(s"inc-4"))
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(4, active = true))
|
||||
}
|
||||
|
||||
"preserve user stash when persist failed" in {
|
||||
val c = spawn(counter(PersistenceId.ofUniqueId("fail-fifth-b")))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
val stateProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment("inc-1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("inc-1"))
|
||||
|
||||
c ! Deactivate("deact", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("deact"))
|
||||
|
||||
c ! Increment("inc-2", ackProbe.ref)
|
||||
c ! Increment("inc-3", ackProbe.ref) // this will fail when unstashed
|
||||
c ! Increment("inc-4", ackProbe.ref)
|
||||
c ! Increment("inc-5", ackProbe.ref)
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(1, active = false))
|
||||
|
||||
c ! Activate("act", ackProbe.ref)
|
||||
c ! Increment("inc-6", ackProbe.ref)
|
||||
|
||||
ackProbe.expectMessage(Ack("act"))
|
||||
ackProbe.expectMessage(Ack("inc-2"))
|
||||
// inc-3 failed
|
||||
// inc-4, inc-5 still in user stash and processed due to UnstashAll that was in progress
|
||||
ackProbe.expectMessage(Ack("inc-4"))
|
||||
ackProbe.expectMessage(Ack("inc-5"))
|
||||
ackProbe.expectMessage(Ack("inc-6"))
|
||||
|
||||
c ! GetValue(stateProbe.ref)
|
||||
stateProbe.expectMessage(State(5, active = true))
|
||||
}
|
||||
|
||||
"discard when stash has reached limit with default dropped setting" in {
|
||||
val probe = TestProbe[AnyRef]()
|
||||
system.toClassic.eventStream.subscribe(probe.ref.toClassic, classOf[Dropped])
|
||||
val behavior = Behaviors.setup[String] { context =>
|
||||
EventSourcedBehavior[String, String, Boolean](
|
||||
persistenceId = PersistenceId.ofUniqueId("stash-is-full-drop"),
|
||||
emptyState = false,
|
||||
commandHandler = { (state, command) =>
|
||||
state match {
|
||||
case false =>
|
||||
command match {
|
||||
case "ping" =>
|
||||
probe.ref ! "pong"
|
||||
Effect.none
|
||||
case "start-stashing" =>
|
||||
Effect.persist("start-stashing")
|
||||
case msg =>
|
||||
probe.ref ! msg
|
||||
Effect.none
|
||||
}
|
||||
|
||||
case true =>
|
||||
command match {
|
||||
case "unstash" =>
|
||||
Effect.persist("unstash").thenRun((_: Boolean) => context.self ! "done-unstashing").thenUnstashAll()
|
||||
case _ =>
|
||||
Effect.stash()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
case (_, "start-stashing") => true
|
||||
case (_, "unstash") => false
|
||||
case (_, _) => throw new IllegalArgumentException()
|
||||
})
|
||||
}
|
||||
|
||||
val c = spawn(behavior)
|
||||
|
||||
// make sure it completed recovery, before we try to overfill the stash
|
||||
c ! "ping"
|
||||
probe.expectMessage("pong")
|
||||
|
||||
c ! "start-stashing"
|
||||
|
||||
val limit = system.settings.config.getInt("pekko.persistence.typed.stash-capacity")
|
||||
LoggingTestKit.warn("Stash buffer is full, dropping message").expect {
|
||||
(0 to limit).foreach { n =>
|
||||
c ! s"cmd-$n" // limit triggers overflow
|
||||
}
|
||||
probe.expectMessageType[Dropped]
|
||||
}
|
||||
|
||||
// we can still unstash and continue interacting
|
||||
c ! "unstash"
|
||||
(0 until limit).foreach { n =>
|
||||
probe.expectMessage(s"cmd-$n")
|
||||
}
|
||||
probe.expectMessage("done-unstashing") // before actually unstashing, see above
|
||||
|
||||
c ! "ping"
|
||||
probe.expectMessage("pong")
|
||||
}
|
||||
|
||||
"fail when stash has reached limit if configured to fail" in {
|
||||
// persistence settings is system wide, so we need to have a custom testkit/actorsystem here
|
||||
val failStashTestKit = ActorTestKit(
|
||||
"EventSourcedBehaviorStashSpec-stash-overflow-fail",
|
||||
ConfigFactory
|
||||
.parseString("pekko.persistence.typed.stash-overflow-strategy=fail")
|
||||
.withFallback(EventSourcedBehaviorStashSpec.conf))
|
||||
try {
|
||||
val probe = failStashTestKit.createTestProbe[AnyRef]()
|
||||
val behavior =
|
||||
EventSourcedBehavior[String, String, String](
|
||||
PersistenceId.ofUniqueId("stash-is-full-fail"),
|
||||
"",
|
||||
commandHandler = {
|
||||
case (_, "ping") =>
|
||||
probe.ref ! "pong"
|
||||
Effect.none
|
||||
case (_, _) =>
|
||||
Effect.stash()
|
||||
},
|
||||
(state, _) => state)
|
||||
|
||||
val c = failStashTestKit.spawn(behavior)
|
||||
|
||||
// make sure recovery completed
|
||||
c ! "ping"
|
||||
probe.expectMessage("pong")
|
||||
|
||||
LoggingTestKit
|
||||
.error[StashOverflowException]
|
||||
.expect {
|
||||
val limit = system.settings.config.getInt("pekko.persistence.typed.stash-capacity")
|
||||
(0 to limit).foreach { n =>
|
||||
c ! s"cmd-$n" // limit triggers overflow
|
||||
}
|
||||
probe.expectTerminated(c, 10.seconds)
|
||||
}(failStashTestKit.system)
|
||||
} finally {
|
||||
failStashTestKit.shutdownTestKit()
|
||||
}
|
||||
}
|
||||
|
||||
"stop from PoisonPill even though user stash is not empty" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
|
||||
c ! Increment("1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("1"))
|
||||
|
||||
c ! Deactivate("2", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("2"))
|
||||
|
||||
// stash 3 and 4
|
||||
c ! Increment("3", ackProbe.ref)
|
||||
c ! Increment("4", ackProbe.ref)
|
||||
|
||||
c.toClassic ! PoisonPill
|
||||
ackProbe.expectTerminated(c)
|
||||
}
|
||||
|
||||
"stop from PoisonPill after unstashing completed" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
|
||||
c ! Increment("1", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("1"))
|
||||
|
||||
c ! Deactivate("2", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("2"))
|
||||
|
||||
// stash 3 and 4
|
||||
c ! Increment("3", ackProbe.ref)
|
||||
c ! Increment("4", ackProbe.ref)
|
||||
|
||||
// start unstashing
|
||||
c ! Activate("5", ackProbe.ref)
|
||||
c.toClassic ! PoisonPill
|
||||
// 6 shouldn't make it, already stopped
|
||||
c ! Increment("6", ackProbe.ref)
|
||||
|
||||
ackProbe.expectMessage(Ack("5"))
|
||||
// not stopped before 3 and 4 were processed
|
||||
ackProbe.expectMessage(Ack("3"))
|
||||
ackProbe.expectMessage(Ack("4"))
|
||||
|
||||
ackProbe.expectTerminated(c)
|
||||
|
||||
// 6 shouldn't make it, already stopped
|
||||
ackProbe.expectNoMessage(100.millis)
|
||||
}
|
||||
|
||||
"stop from PoisonPill after recovery completed" in {
|
||||
val pid = nextPid()
|
||||
val c = spawn(counter(pid))
|
||||
val ackProbe = TestProbe[Ack]()
|
||||
|
||||
c ! Increment("1", ackProbe.ref)
|
||||
c ! Increment("2", ackProbe.ref)
|
||||
c ! Increment("3", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("1"))
|
||||
ackProbe.expectMessage(Ack("2"))
|
||||
ackProbe.expectMessage(Ack("3"))
|
||||
|
||||
val signalProbe = TestProbe[String]()
|
||||
val c2 = spawn(counter(pid, Some(signalProbe.ref)))
|
||||
// this PoisonPill will most likely be received in RequestingRecoveryPermit since it's sent immediately
|
||||
c2.toClassic ! PoisonPill
|
||||
signalProbe.expectMessage("RecoveryCompleted-3")
|
||||
signalProbe.expectMessage("PostStop")
|
||||
|
||||
val c3 = spawn(counter(pid, Some(signalProbe.ref)))
|
||||
// this PoisonPill will most likely be received in RequestingRecoveryPermit since it's sent slightly afterwards
|
||||
Thread.sleep(1)
|
||||
c3.toClassic ! PoisonPill
|
||||
c3 ! Increment("4", ackProbe.ref)
|
||||
signalProbe.expectMessage("RecoveryCompleted-3")
|
||||
signalProbe.expectMessage("PostStop")
|
||||
ackProbe.expectNoMessage(20.millis)
|
||||
|
||||
val c4 = spawn(counter(pid, Some(signalProbe.ref)))
|
||||
signalProbe.expectMessage("RecoveryCompleted-3")
|
||||
// this PoisonPill will be received in Running
|
||||
c4.toClassic ! PoisonPill
|
||||
c4 ! Increment("4", ackProbe.ref)
|
||||
signalProbe.expectMessage("PostStop")
|
||||
ackProbe.expectNoMessage(20.millis)
|
||||
|
||||
val c5 = spawn(counter(pid, Some(signalProbe.ref)))
|
||||
signalProbe.expectMessage("RecoveryCompleted-3")
|
||||
c5 ! Increment("4", ackProbe.ref)
|
||||
c5 ! Increment("5", ackProbe.ref)
|
||||
// this PoisonPill will most likely be received in PersistingEvents
|
||||
c5.toClassic ! PoisonPill
|
||||
c5 ! Increment("6", ackProbe.ref)
|
||||
ackProbe.expectMessage(Ack("4"))
|
||||
ackProbe.expectMessage(Ack("5"))
|
||||
signalProbe.expectMessage("PostStop")
|
||||
ackProbe.expectNoMessage(20.millis)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import scala.concurrent.duration._
|
||||
|
||||
object EventSourcedBehaviorTimersSpec {
|
||||
|
||||
val journalId = "event-sourced-behavior-timers-spec"
|
||||
|
||||
def testBehavior(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
Behaviors.withTimers { timers =>
|
||||
EventSourcedBehavior[String, String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) =>
|
||||
command match {
|
||||
case "scheduled" =>
|
||||
probe ! "scheduled"
|
||||
Effect.none
|
||||
case "cmd-0" =>
|
||||
timers.startSingleTimer("key", "scheduled", Duration.Zero)
|
||||
Effect.none
|
||||
case _ =>
|
||||
timers.startSingleTimer("key", "scheduled", Duration.Zero)
|
||||
Effect.persist(command).thenRun(_ => probe ! command)
|
||||
},
|
||||
eventHandler = (state, evt) => state + evt)
|
||||
}
|
||||
}
|
||||
|
||||
def testTimerFromSetupBehavior(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
Behaviors.withTimers { timers =>
|
||||
timers.startSingleTimer("key", "scheduled", Duration.Zero)
|
||||
|
||||
EventSourcedBehavior[String, String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) =>
|
||||
command match {
|
||||
case "scheduled" =>
|
||||
probe ! "scheduled"
|
||||
Effect.none
|
||||
case _ =>
|
||||
Effect.persist(command).thenRun(_ => probe ! command)
|
||||
},
|
||||
eventHandler = (state, evt) => state + evt)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorTimersSpec
|
||||
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorTimersSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"EventSourcedBehavior withTimers" must {
|
||||
|
||||
"be able to schedule message" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testBehavior(pid, probe.ref))
|
||||
|
||||
ref ! "cmd-0"
|
||||
probe.expectMessage("scheduled")
|
||||
}
|
||||
|
||||
"not discard timer msg due to stashing" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testBehavior(pid, probe.ref))
|
||||
|
||||
ref ! "cmd-1"
|
||||
probe.expectMessage("cmd-1")
|
||||
probe.expectMessage("scheduled")
|
||||
}
|
||||
|
||||
"be able to schedule message from setup" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testTimerFromSetupBehavior(pid, probe.ref))
|
||||
|
||||
probe.expectMessage("scheduled")
|
||||
|
||||
(1 to 20).foreach { n =>
|
||||
ref ! s"cmd-$n"
|
||||
}
|
||||
probe.receiveMessages(20)
|
||||
|
||||
// start new instance that is likely to stash the timer message while replaying
|
||||
spawn(testTimerFromSetupBehavior(pid, probe.ref))
|
||||
probe.expectMessage("scheduled")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.TestException
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.LoggingTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.actor.typed._
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.typed.internal.EventSourcedBehaviorImpl.WriterIdentity
|
||||
import pekko.persistence.typed.internal.BehaviorSetup
|
||||
import pekko.persistence.typed.internal.EventSourcedSettings
|
||||
import pekko.persistence.typed.internal.InternalProtocol
|
||||
import pekko.persistence.typed.internal.NoOpSnapshotAdapter
|
||||
import pekko.persistence.typed.internal.StashState
|
||||
import pekko.persistence.typed.NoOpEventAdapter
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.{ Recovery => ClassicRecovery }
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import pekko.util.ConstantFun
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object EventSourcedBehaviorWatchSpec {
|
||||
sealed trait Command extends CborSerializable
|
||||
case object Fail extends Command
|
||||
case object Stop extends Command
|
||||
final case class ChildHasFailed(t: pekko.actor.typed.ChildFailed)
|
||||
final case class HasTerminated(ref: ActorRef[_])
|
||||
}
|
||||
|
||||
class EventSourcedBehaviorWatchSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedBehaviorSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedBehaviorWatchSpec._
|
||||
|
||||
private val cause = TestException("Dodge this.")
|
||||
|
||||
private val pidCounter = new AtomicInteger(0)
|
||||
|
||||
private def nextPid: PersistenceId = PersistenceId.ofUniqueId(s"${pidCounter.incrementAndGet()}")
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this.getClass)
|
||||
|
||||
private def setup(
|
||||
pf: PartialFunction[(String, Signal), Unit],
|
||||
settings: EventSourcedSettings,
|
||||
context: ActorContext[_]): BehaviorSetup[Command, String, String] =
|
||||
new BehaviorSetup[Command, String, String](
|
||||
context.asInstanceOf[ActorContext[InternalProtocol]],
|
||||
nextPid,
|
||||
emptyState = "",
|
||||
commandHandler = (_, _) => Effect.none,
|
||||
eventHandler = (state, evt) => state + evt,
|
||||
WriterIdentity.newIdentity(),
|
||||
pf,
|
||||
_ => Set.empty[String],
|
||||
NoOpEventAdapter.instance[String],
|
||||
NoOpSnapshotAdapter.instance[String],
|
||||
snapshotWhen = ConstantFun.scalaAnyThreeToFalse,
|
||||
ClassicRecovery(),
|
||||
RetentionCriteria.disabled,
|
||||
holdingRecoveryPermit = false,
|
||||
settings = settings,
|
||||
stashState = new StashState(context.asInstanceOf[ActorContext[InternalProtocol]], settings),
|
||||
replication = None,
|
||||
publishEvents = false,
|
||||
internalLoggerFactory = () => logger)
|
||||
|
||||
"A typed persistent parent actor watching a child" must {
|
||||
|
||||
"throw a DeathPactException from parent when not handling the child Terminated signal" in {
|
||||
|
||||
val parent =
|
||||
spawn(Behaviors.setup[Command] { context =>
|
||||
val child = context.spawnAnonymous(Behaviors.receive[Command] { (_, _) =>
|
||||
throw cause
|
||||
})
|
||||
|
||||
context.watch(child)
|
||||
|
||||
EventSourcedBehavior[Command, String, String](nextPid, emptyState = "",
|
||||
commandHandler = (_, cmd) => {
|
||||
child ! cmd
|
||||
Effect.none
|
||||
}, eventHandler = (state, evt) => state + evt)
|
||||
})
|
||||
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
LoggingTestKit.error[DeathPactException].expect {
|
||||
parent ! Fail
|
||||
}
|
||||
}
|
||||
createTestProbe().expectTerminated(parent)
|
||||
}
|
||||
|
||||
"behave as expected if a user's signal handler is side effecting" in {
|
||||
val signalHandler: PartialFunction[(String, Signal), Unit] = {
|
||||
case (_, RecoveryCompleted) =>
|
||||
java.time.Instant.now.getNano
|
||||
Behaviors.same
|
||||
}
|
||||
|
||||
Behaviors.setup[Command] { context =>
|
||||
val settings = EventSourcedSettings(context.system, "", "")
|
||||
|
||||
setup(signalHandler, settings, context).onSignal("", RecoveryCompleted, false) shouldEqual true
|
||||
setup(PartialFunction.empty, settings, context).onSignal("", RecoveryCompleted, false) shouldEqual false
|
||||
|
||||
Behaviors.empty
|
||||
}
|
||||
|
||||
val parent =
|
||||
spawn(Behaviors.setup[Command] { context =>
|
||||
val child = context.spawnAnonymous(Behaviors.receive[Command] { (_, _) =>
|
||||
throw cause
|
||||
})
|
||||
|
||||
context.watch(child)
|
||||
|
||||
EventSourcedBehavior[Command, String, String](nextPid, emptyState = "",
|
||||
commandHandler = (_, cmd) => {
|
||||
child ! cmd
|
||||
Effect.none
|
||||
}, eventHandler = (state, evt) => state + evt).receiveSignal(signalHandler)
|
||||
})
|
||||
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
LoggingTestKit.error[DeathPactException].expect {
|
||||
parent ! Fail
|
||||
}
|
||||
}
|
||||
createTestProbe().expectTerminated(parent)
|
||||
}
|
||||
|
||||
"receive a Terminated when handling the signal" in {
|
||||
val probe = TestProbe[AnyRef]()
|
||||
|
||||
val parent =
|
||||
spawn(Behaviors.setup[Stop.type] { context =>
|
||||
val child = context.spawnAnonymous(Behaviors.setup[Stop.type] { c =>
|
||||
Behaviors.receive[Stop.type] { (_, _) =>
|
||||
context.stop(c.self)
|
||||
Behaviors.stopped
|
||||
}
|
||||
})
|
||||
|
||||
probe.ref ! child
|
||||
context.watch(child)
|
||||
|
||||
EventSourcedBehavior[Stop.type, String, String](nextPid, emptyState = "",
|
||||
commandHandler = (_, cmd) => {
|
||||
child ! cmd
|
||||
Effect.none
|
||||
}, eventHandler = (state, evt) => state + evt).receiveSignal {
|
||||
case (_, t: Terminated) =>
|
||||
probe.ref ! HasTerminated(t.ref)
|
||||
Behaviors.stopped
|
||||
}
|
||||
})
|
||||
|
||||
val child = probe.expectMessageType[ActorRef[Stop.type]]
|
||||
|
||||
parent ! Stop
|
||||
probe.expectMessageType[HasTerminated].ref shouldEqual child
|
||||
}
|
||||
|
||||
"receive a ChildFailed when handling the signal" in {
|
||||
val probe = TestProbe[AnyRef]()
|
||||
|
||||
val parent =
|
||||
spawn(Behaviors.setup[Fail.type] { context =>
|
||||
val child = context.spawnAnonymous(Behaviors.receive[Fail.type] { (_, _) =>
|
||||
throw cause
|
||||
})
|
||||
|
||||
probe.ref ! child
|
||||
context.watch(child)
|
||||
|
||||
EventSourcedBehavior[Fail.type, String, String](nextPid, emptyState = "",
|
||||
commandHandler = (_, cmd) => {
|
||||
child ! cmd
|
||||
Effect.none
|
||||
}, eventHandler = (state, evt) => state + evt).receiveSignal {
|
||||
case (_, t: ChildFailed) =>
|
||||
probe.ref ! ChildHasFailed(t)
|
||||
Behaviors.same
|
||||
}
|
||||
})
|
||||
|
||||
val child = probe.expectMessageType[ActorRef[Fail.type]]
|
||||
|
||||
LoggingTestKit.error[TestException].expect {
|
||||
parent ! Fail
|
||||
}
|
||||
val failed = probe.expectMessageType[ChildHasFailed].t
|
||||
failed.ref shouldEqual child
|
||||
failed.cause shouldEqual cause
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.query.EventEnvelope
|
||||
import pekko.persistence.query.PersistenceQuery
|
||||
import pekko.persistence.query.Sequence
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.EventAdapter
|
||||
import pekko.persistence.typed.EventSeq
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import pekko.stream.scaladsl.Sink
|
||||
import pekko.testkit.JavaSerializable
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object EventSourcedEventAdapterSpec {
|
||||
|
||||
case class Wrapper(event: String) extends CborSerializable
|
||||
class WrapperEventAdapter extends EventAdapter[String, Wrapper] {
|
||||
override def toJournal(e: String): Wrapper = Wrapper("<" + e)
|
||||
override def fromJournal(p: Wrapper, manifest: String): EventSeq[String] = EventSeq.single(p.event + ">")
|
||||
override def manifest(event: String): String = ""
|
||||
}
|
||||
|
||||
class FilterEventAdapter extends EventAdapter[String, String] {
|
||||
override def toJournal(e: String): String = e.toUpperCase()
|
||||
|
||||
override def fromJournal(p: String, manifest: String): EventSeq[String] = {
|
||||
if (p == "B") EventSeq.empty
|
||||
else EventSeq.single(p)
|
||||
}
|
||||
|
||||
override def manifest(event: String): String = ""
|
||||
}
|
||||
|
||||
class SplitEventAdapter extends EventAdapter[String, String] {
|
||||
override def toJournal(e: String): String = e.toUpperCase()
|
||||
|
||||
override def fromJournal(p: String, manifest: String): EventSeq[String] = {
|
||||
EventSeq(p.map("<" + _.toString + ">"))
|
||||
}
|
||||
|
||||
override def manifest(event: String): String = ""
|
||||
}
|
||||
|
||||
class EventAdapterWithManifest extends EventAdapter[String, String] {
|
||||
override def toJournal(e: String): String = e.toUpperCase()
|
||||
|
||||
override def fromJournal(p: String, manifest: String): EventSeq[String] = {
|
||||
EventSeq.single(p + manifest)
|
||||
}
|
||||
|
||||
override def manifest(event: String): String = event.length.toString
|
||||
}
|
||||
|
||||
// generics doesn't work with Jackson, so using Java serialization
|
||||
case class GenericWrapper[T](event: T) extends JavaSerializable
|
||||
class GenericWrapperEventAdapter[T] extends EventAdapter[T, GenericWrapper[T]] {
|
||||
override def toJournal(e: T): GenericWrapper[T] = GenericWrapper(e)
|
||||
override def fromJournal(p: GenericWrapper[T], manifest: String): EventSeq[T] = EventSeq.single(p.event)
|
||||
override def manifest(event: T): String = ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EventSourcedEventAdapterSpec
|
||||
extends ScalaTestWithActorTestKit(ConfigFactory.parseString("""
|
||||
pekko.persistence.testkit.events.serialize = true""").withFallback(PersistenceTestKitPlugin.config))
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import EventSourcedBehaviorSpec._
|
||||
import EventSourcedEventAdapterSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
val queries: PersistenceTestKitReadJournal =
|
||||
PersistenceQuery(system).readJournalFor[PersistenceTestKitReadJournal](PersistenceTestKitReadJournal.Identifier)
|
||||
|
||||
private def behavior(pid: PersistenceId, probe: ActorRef[String]): EventSourcedBehavior[String, String, String] =
|
||||
EventSourcedBehavior(pid, "",
|
||||
commandHandler = { (_, command) =>
|
||||
Effect.persist(command).thenRun(newState => probe ! newState)
|
||||
},
|
||||
eventHandler = { (state, evt) =>
|
||||
state + evt
|
||||
})
|
||||
|
||||
"Event adapter" must {
|
||||
|
||||
"wrap single events" in {
|
||||
val probe = TestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(behavior(pid, probe.ref).eventAdapter(new WrapperEventAdapter))
|
||||
|
||||
ref ! "a"
|
||||
ref ! "b"
|
||||
probe.expectMessage("a")
|
||||
probe.expectMessage("ab")
|
||||
|
||||
// replay
|
||||
val ref2 = spawn(behavior(pid, probe.ref).eventAdapter(new WrapperEventAdapter))
|
||||
ref2 ! "c"
|
||||
probe.expectMessage("<a><b>c")
|
||||
}
|
||||
|
||||
"filter unused events" in {
|
||||
val probe = TestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(behavior(pid, probe.ref).eventAdapter(new FilterEventAdapter))
|
||||
|
||||
ref ! "a"
|
||||
ref ! "b"
|
||||
ref ! "c"
|
||||
probe.expectMessage("a")
|
||||
probe.expectMessage("ab")
|
||||
probe.expectMessage("abc")
|
||||
|
||||
// replay
|
||||
val ref2 = spawn(behavior(pid, probe.ref).eventAdapter(new FilterEventAdapter))
|
||||
ref2 ! "d"
|
||||
probe.expectMessage("ACd")
|
||||
}
|
||||
|
||||
"split one event into several" in {
|
||||
val probe = TestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(behavior(pid, probe.ref).eventAdapter(new SplitEventAdapter))
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("a")
|
||||
probe.expectMessage("abc")
|
||||
|
||||
// replay
|
||||
val ref2 = spawn(behavior(pid, probe.ref).eventAdapter(new SplitEventAdapter))
|
||||
ref2 ! "d"
|
||||
probe.expectMessage("<A><B><C>d")
|
||||
}
|
||||
|
||||
"support manifest" in {
|
||||
val probe = TestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(behavior(pid, probe.ref).eventAdapter(new EventAdapterWithManifest))
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bcd"
|
||||
probe.expectMessage("a")
|
||||
probe.expectMessage("abcd")
|
||||
|
||||
// replay
|
||||
val ref2 = spawn(behavior(pid, probe.ref).eventAdapter(new EventAdapterWithManifest))
|
||||
ref2 ! "e"
|
||||
probe.expectMessage("A1BCD3e")
|
||||
}
|
||||
|
||||
"adapt events" in {
|
||||
val pid = nextPid()
|
||||
val c = spawn(Behaviors.setup[Command] { ctx =>
|
||||
val persistentBehavior = counter(ctx, pid)
|
||||
|
||||
persistentBehavior.eventAdapter(new GenericWrapperEventAdapter[Event])
|
||||
})
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
|
||||
val events = queries.currentEventsByPersistenceId(pid.id).runWith(Sink.seq).futureValue
|
||||
events shouldEqual List(EventEnvelope(Sequence(1), pid.id, 1, GenericWrapper(Incremented(1)), 0L))
|
||||
|
||||
val c2 =
|
||||
spawn(Behaviors.setup[Command](ctx => counter(ctx, pid).eventAdapter(new GenericWrapperEventAdapter[Event])))
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
|
||||
}
|
||||
|
||||
"adapter multiple events with persist all" in {
|
||||
val pid = nextPid()
|
||||
val c =
|
||||
spawn(Behaviors.setup[Command](ctx => counter(ctx, pid).eventAdapter(new GenericWrapperEventAdapter[Event])))
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! IncrementWithPersistAll(2)
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(2, Vector(0, 1)))
|
||||
|
||||
val events = queries.currentEventsByPersistenceId(pid.id).runWith(Sink.seq).futureValue
|
||||
events shouldEqual List(
|
||||
EventEnvelope(Sequence(1), pid.id, 1, GenericWrapper(Incremented(1)), 0L),
|
||||
EventEnvelope(Sequence(2), pid.id, 2, GenericWrapper(Incremented(1)), 0L))
|
||||
|
||||
val c2 =
|
||||
spawn(Behaviors.setup[Command](ctx => counter(ctx, pid).eventAdapter(new GenericWrapperEventAdapter[Event])))
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(2, Vector(0, 1)))
|
||||
}
|
||||
|
||||
"adapt and tag events" in {
|
||||
val pid = nextPid()
|
||||
val c = spawn(Behaviors.setup[Command](ctx =>
|
||||
counter(ctx, pid).withTagger(_ => Set("tag99")).eventAdapter(new GenericWrapperEventAdapter[Event])))
|
||||
val replyProbe = TestProbe[State]()
|
||||
|
||||
c ! Increment
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
|
||||
val events = queries.currentEventsByPersistenceId(pid.id).runWith(Sink.seq).futureValue
|
||||
events shouldEqual List(EventEnvelope(Sequence(1), pid.id, 1, GenericWrapper(Incremented(1)), 0L))
|
||||
|
||||
val c2 =
|
||||
spawn(Behaviors.setup[Command](ctx => counter(ctx, pid).eventAdapter(new GenericWrapperEventAdapter[Event])))
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(State(1, Vector(0)))
|
||||
|
||||
val taggedEvents = queries.currentEventsByTag("tag99").runWith(Sink.seq).futureValue
|
||||
taggedEvents shouldEqual List(EventEnvelope(Sequence(1), pid.id, 1, GenericWrapper(Incremented(1)), 0L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* Copyright (C) 2009-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object EventSourcedSequenceNumberSpec {
|
||||
|
||||
private val conf = ConfigFactory.parseString(s"""
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
pekko.persistence.journal.inmem.test-serialization = on
|
||||
pekko.persistence.snapshot-store.plugin = "slow-snapshot-store"
|
||||
slow-snapshot-store.class = "${classOf[SlowInMemorySnapshotStore].getName}"
|
||||
""")
|
||||
|
||||
}
|
||||
|
||||
class EventSourcedSequenceNumberSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedSequenceNumberSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
private def behavior(pid: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup(ctx =>
|
||||
EventSourcedBehavior[String, String, String](pid, "",
|
||||
{
|
||||
(state, command) =>
|
||||
state match {
|
||||
case "stashing" =>
|
||||
command match {
|
||||
case "unstash" =>
|
||||
probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} unstash"
|
||||
Effect.persist("normal").thenUnstashAll()
|
||||
case _ =>
|
||||
Effect.stash()
|
||||
}
|
||||
case _ =>
|
||||
command match {
|
||||
case "cmd" =>
|
||||
probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} onCommand"
|
||||
Effect
|
||||
.persist("evt")
|
||||
.thenRun(_ => probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} thenRun")
|
||||
case "cmd3" =>
|
||||
probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} onCommand"
|
||||
Effect
|
||||
.persist("evt1", "evt2", "evt3")
|
||||
.thenRun(_ => probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} thenRun")
|
||||
case "stash" =>
|
||||
probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} stash"
|
||||
Effect.persist("stashing")
|
||||
case "snapshot" =>
|
||||
Effect.persist("snapshot")
|
||||
}
|
||||
}
|
||||
},
|
||||
{ (_, evt) =>
|
||||
probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} eventHandler $evt"
|
||||
evt
|
||||
}).snapshotWhen((_, event, _) => event == "snapshot").receiveSignal {
|
||||
case (_, RecoveryCompleted) =>
|
||||
probe ! s"${EventSourcedBehavior.lastSequenceNumber(ctx)} onRecoveryComplete"
|
||||
})
|
||||
|
||||
"The sequence number" must {
|
||||
|
||||
"be accessible in the handlers" in {
|
||||
val probe = TestProbe[String]()
|
||||
val ref = spawn(behavior(PersistenceId.ofUniqueId("ess-1"), probe.ref))
|
||||
probe.expectMessage("0 onRecoveryComplete")
|
||||
|
||||
ref ! "cmd"
|
||||
probe.expectMessage("0 onCommand")
|
||||
probe.expectMessage("1 eventHandler evt")
|
||||
probe.expectMessage("1 thenRun")
|
||||
|
||||
ref ! "cmd"
|
||||
probe.expectMessage("1 onCommand")
|
||||
probe.expectMessage("2 eventHandler evt")
|
||||
probe.expectMessage("2 thenRun")
|
||||
|
||||
ref ! "cmd3"
|
||||
probe.expectMessage("2 onCommand")
|
||||
probe.expectMessage("3 eventHandler evt1")
|
||||
probe.expectMessage("4 eventHandler evt2")
|
||||
probe.expectMessage("5 eventHandler evt3")
|
||||
probe.expectMessage("5 thenRun")
|
||||
|
||||
testKit.stop(ref)
|
||||
probe.expectTerminated(ref)
|
||||
|
||||
// and during replay
|
||||
val ref2 = spawn(behavior(PersistenceId.ofUniqueId("ess-1"), probe.ref))
|
||||
probe.expectMessage("1 eventHandler evt")
|
||||
probe.expectMessage("2 eventHandler evt")
|
||||
probe.expectMessage("3 eventHandler evt1")
|
||||
probe.expectMessage("4 eventHandler evt2")
|
||||
probe.expectMessage("5 eventHandler evt3")
|
||||
probe.expectMessage("5 onRecoveryComplete")
|
||||
|
||||
ref2 ! "cmd"
|
||||
probe.expectMessage("5 onCommand")
|
||||
probe.expectMessage("6 eventHandler evt")
|
||||
probe.expectMessage("6 thenRun")
|
||||
}
|
||||
|
||||
"be available while unstashing" in {
|
||||
val probe = TestProbe[String]()
|
||||
val ref = spawn(behavior(PersistenceId.ofUniqueId("ess-2"), probe.ref))
|
||||
probe.expectMessage("0 onRecoveryComplete")
|
||||
|
||||
ref ! "stash"
|
||||
ref ! "cmd"
|
||||
ref ! "cmd"
|
||||
ref ! "cmd3"
|
||||
ref ! "unstash"
|
||||
probe.expectMessage("0 stash")
|
||||
probe.expectMessage("1 eventHandler stashing")
|
||||
probe.expectMessage("1 unstash")
|
||||
probe.expectMessage("2 eventHandler normal")
|
||||
probe.expectMessage("2 onCommand")
|
||||
probe.expectMessage("3 eventHandler evt")
|
||||
probe.expectMessage("3 thenRun")
|
||||
probe.expectMessage("3 onCommand")
|
||||
probe.expectMessage("4 eventHandler evt")
|
||||
probe.expectMessage("4 thenRun")
|
||||
probe.expectMessage("4 onCommand") // cmd3
|
||||
probe.expectMessage("5 eventHandler evt1")
|
||||
probe.expectMessage("6 eventHandler evt2")
|
||||
probe.expectMessage("7 eventHandler evt3")
|
||||
probe.expectMessage("7 thenRun")
|
||||
}
|
||||
|
||||
// reproducer for #27935
|
||||
"not fail when snapshotting" in {
|
||||
val probe = TestProbe[String]()
|
||||
val ref = spawn(behavior(PersistenceId.ofUniqueId("ess-3"), probe.ref))
|
||||
probe.expectMessage("0 onRecoveryComplete")
|
||||
|
||||
ref ! "cmd"
|
||||
ref ! "snapshot"
|
||||
ref ! "cmd"
|
||||
|
||||
probe.expectMessage("0 onCommand") // first command
|
||||
probe.expectMessage("1 eventHandler evt")
|
||||
probe.expectMessage("1 thenRun")
|
||||
probe.expectMessage("2 eventHandler snapshot")
|
||||
probe.expectMessage("2 onCommand") // second command
|
||||
probe.expectMessage("3 eventHandler evt")
|
||||
probe.expectMessage("3 thenRun")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.persistence.query.PersistenceQuery
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.PersistenceTestKitSnapshotPlugin
|
||||
import pekko.persistence.testkit.query.scaladsl.PersistenceTestKitReadJournal
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.SnapshotAdapter
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object EventSourcedSnapshotAdapterSpec {
|
||||
|
||||
case class State(s: String) extends CborSerializable
|
||||
case class Command(c: String) extends CborSerializable
|
||||
case class Event(e: String) extends CborSerializable
|
||||
case class PersistedState(s: String) extends CborSerializable
|
||||
}
|
||||
|
||||
class EventSourcedSnapshotAdapterSpec
|
||||
extends ScalaTestWithActorTestKit(
|
||||
PersistenceTestKitPlugin.config.withFallback(PersistenceTestKitSnapshotPlugin.config))
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import EventSourcedSnapshotAdapterSpec._
|
||||
import pekko.actor.typed.scaladsl.adapter._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
val queries: PersistenceTestKitReadJournal =
|
||||
PersistenceQuery(system.toClassic)
|
||||
.readJournalFor[PersistenceTestKitReadJournal](PersistenceTestKitReadJournal.Identifier)
|
||||
|
||||
private def behavior(pid: PersistenceId, probe: ActorRef[State]): EventSourcedBehavior[Command, Event, State] =
|
||||
EventSourcedBehavior[Command, Event, State](
|
||||
pid,
|
||||
State(""),
|
||||
commandHandler = { (state, command) =>
|
||||
command match {
|
||||
case Command(c) if c == "shutdown" =>
|
||||
Effect.stop()
|
||||
case Command(c) if c == "get" =>
|
||||
probe.tell(state)
|
||||
Effect.none
|
||||
case _ =>
|
||||
Effect.persist(Event(command.c)).thenRun(newState => probe ! newState)
|
||||
}
|
||||
},
|
||||
eventHandler = { (state, evt) =>
|
||||
state.copy(s = state.s + "|" + evt.e)
|
||||
})
|
||||
|
||||
"Snapshot adapter" must {
|
||||
|
||||
"adapt snapshots to any" in {
|
||||
val pid = nextPid()
|
||||
val stateProbe = TestProbe[State]()
|
||||
val snapshotFromJournal = TestProbe[PersistedState]()
|
||||
val snapshotToJournal = TestProbe[State]()
|
||||
val b = behavior(pid, stateProbe.ref)
|
||||
.snapshotAdapter(new SnapshotAdapter[State]() {
|
||||
override def toJournal(state: State): Any = {
|
||||
snapshotToJournal.ref.tell(state)
|
||||
PersistedState(state.s)
|
||||
}
|
||||
override def fromJournal(from: Any): State = from match {
|
||||
case ps: PersistedState =>
|
||||
snapshotFromJournal.ref.tell(ps)
|
||||
State(ps.s)
|
||||
case unexpected => throw new RuntimeException(s"Unexpected: $unexpected")
|
||||
}
|
||||
})
|
||||
.snapshotWhen { (_, event, _) =>
|
||||
event.e.contains("snapshot")
|
||||
}
|
||||
|
||||
val ref = spawn(b)
|
||||
|
||||
ref.tell(Command("one"))
|
||||
stateProbe.expectMessage(State("|one"))
|
||||
ref.tell(Command("snapshot now"))
|
||||
stateProbe.expectMessage(State("|one|snapshot now"))
|
||||
snapshotToJournal.expectMessage(State("|one|snapshot now"))
|
||||
ref.tell(Command("shutdown"))
|
||||
|
||||
val ref2 = spawn(b)
|
||||
snapshotFromJournal.expectMessage(PersistedState("|one|snapshot now"))
|
||||
ref2.tell(Command("get"))
|
||||
stateProbe.expectMessage(State("|one|snapshot now"))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (C) 2020-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.adapter._
|
||||
import pekko.persistence.journal.SteppingInmemJournal
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import scala.concurrent.duration._
|
||||
|
||||
// Reproducer for #29401
|
||||
object EventSourcedStashOverflowSpec {
|
||||
|
||||
object EventSourcedStringList {
|
||||
sealed trait Command
|
||||
case class DoNothing(replyTo: ActorRef[Done]) extends Command
|
||||
|
||||
def apply(persistenceId: PersistenceId): Behavior[Command] =
|
||||
EventSourcedBehavior[Command, String, List[String]](
|
||||
persistenceId,
|
||||
Nil,
|
||||
{ (_, command) =>
|
||||
command match {
|
||||
case DoNothing(replyTo) =>
|
||||
Effect.persist(List.empty[String]).thenRun(_ => replyTo ! Done)
|
||||
}
|
||||
},
|
||||
{ (state, event) =>
|
||||
// original reproducer slept 2 seconds here but a pure application of an event seems unlikely to take that long
|
||||
// so instead we delay recovery using a special journal
|
||||
event :: state
|
||||
})
|
||||
}
|
||||
|
||||
def conf =
|
||||
SteppingInmemJournal.config("EventSourcedStashOverflow").withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.persistence {
|
||||
typed {
|
||||
stash-capacity = 1000 # enough to fail on stack size
|
||||
stash-overflow-strategy = "drop"
|
||||
}
|
||||
}
|
||||
"""))
|
||||
}
|
||||
|
||||
class EventSourcedStashOverflowSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedStashOverflowSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import EventSourcedStashOverflowSpec.EventSourcedStringList
|
||||
|
||||
"Stashing in a busy event sourced behavior" must {
|
||||
|
||||
"not cause stack overflow" in {
|
||||
val es = spawn(EventSourcedStringList(PersistenceId.ofUniqueId("id-1")))
|
||||
|
||||
// wait for journal to start
|
||||
val probe = testKit.createTestProbe[Done]()
|
||||
probe.awaitAssert(SteppingInmemJournal.getRef("EventSourcedStashOverflow"), 3.seconds)
|
||||
val journal = SteppingInmemJournal.getRef("EventSourcedStashOverflow")
|
||||
|
||||
val droppedMessageProbe = testKit.createDroppedMessageProbe()
|
||||
val stashCapacity = testKit.config.getInt("pekko.persistence.typed.stash-capacity")
|
||||
|
||||
for (_ <- 0 to (stashCapacity * 2)) {
|
||||
es.tell(EventSourcedStringList.DoNothing(probe.ref))
|
||||
}
|
||||
// capacity + 1 should mean that we get a dropped last message when all stash is filled
|
||||
// while the actor is stuck in replay because journal isn't responding
|
||||
droppedMessageProbe.receiveMessage()
|
||||
implicit val classicSystem: pekko.actor.ActorSystem =
|
||||
testKit.system.toClassic
|
||||
// we only need to do this one step and recovery completes
|
||||
SteppingInmemJournal.step(journal)
|
||||
|
||||
// exactly how many is racy but at least the first stash buffer full should complete
|
||||
probe.receiveMessages(stashCapacity)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright (C) 2009-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.LoggingTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.typed.SnapshotCompleted
|
||||
import pekko.persistence.typed.SnapshotFailed
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
import org.slf4j.event.Level
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
// Note that the spec name here is important since there are heuristics in place to avoid names
|
||||
// starting with EventSourcedBehavior
|
||||
class LoggerSourceSpec
|
||||
extends ScalaTestWithActorTestKit(EventSourcedBehaviorSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
private val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
def behavior: Behavior[String] = Behaviors.setup { ctx =>
|
||||
ctx.log.info("setting-up-behavior")
|
||||
EventSourcedBehavior[String, String, String](nextPid(), emptyState = "",
|
||||
commandHandler = (_, _) => {
|
||||
ctx.log.info("command-received")
|
||||
Effect.persist("evt")
|
||||
},
|
||||
eventHandler = (state, _) => {
|
||||
ctx.log.info("event-received")
|
||||
state
|
||||
}).receiveSignal {
|
||||
case (_, RecoveryCompleted) => ctx.log.info("recovery-completed")
|
||||
case (_, SnapshotCompleted(_)) =>
|
||||
case (_, SnapshotFailed(_, _)) =>
|
||||
}
|
||||
}
|
||||
|
||||
"log from context" should {
|
||||
|
||||
// note that these are somewhat intermingled to make sure no log event from
|
||||
// one test case leaks to another, the actual log class is what is tested in each individual case
|
||||
|
||||
"log from setup" in {
|
||||
LoggingTestKit.info("recovery-completed").expect {
|
||||
eventFilterFor("setting-up-behavior").expect {
|
||||
spawn(behavior)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
"log from recovery completed" in {
|
||||
LoggingTestKit.info("setting-up-behavior").expect {
|
||||
eventFilterFor("recovery-completed").expect {
|
||||
spawn(behavior)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"log from command handler" in {
|
||||
LoggingTestKit.empty
|
||||
.withLogLevel(Level.INFO)
|
||||
.withMessageRegex("(setting-up-behavior|recovery-completed|event-received)")
|
||||
.withOccurrences(3)
|
||||
.expect {
|
||||
eventFilterFor("command-received").expect {
|
||||
spawn(behavior) ! "cmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"log from event handler" in {
|
||||
LoggingTestKit.empty
|
||||
.withLogLevel(Level.INFO)
|
||||
.withMessageRegex("(setting-up-behavior|recovery-completed|command-received)")
|
||||
.withOccurrences(3)
|
||||
.expect {
|
||||
eventFilterFor("event-received").expect {
|
||||
spawn(behavior) ! "cmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"use the user provided name" in {
|
||||
|
||||
val behavior: Behavior[String] = Behaviors.setup[String] { ctx =>
|
||||
ctx.setLoggerName("my-custom-name")
|
||||
EventSourcedBehavior[String, String, String](nextPid(), emptyState = "",
|
||||
commandHandler = (_, _) => {
|
||||
ctx.log.info("command-received")
|
||||
Effect.persist("evt")
|
||||
}, eventHandler = (state, _) => state)
|
||||
}
|
||||
|
||||
val actor = spawn(behavior)
|
||||
|
||||
LoggingTestKit.info("command-received").withLoggerName("my-custom-name").withOccurrences(1).expect {
|
||||
actor ! "cmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def eventFilterFor(logMsg: String) =
|
||||
LoggingTestKit.custom { logEvent =>
|
||||
logEvent.message == logMsg && logEvent.loggerName == classOf[LoggerSourceSpec].getName
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (C) 2018-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.TestKitSettings
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object NullEmptyStateSpec {
|
||||
|
||||
private val conf = ConfigFactory.parseString(s"""
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
pekko.persistence.journal.inmem.test-serialization = on
|
||||
""")
|
||||
}
|
||||
|
||||
class NullEmptyStateSpec
|
||||
extends ScalaTestWithActorTestKit(NullEmptyStateSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
implicit val testSettings: TestKitSettings = TestKitSettings(system)
|
||||
|
||||
def nullState(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
EventSourcedBehavior[String, String, String](
|
||||
persistenceId,
|
||||
emptyState = null,
|
||||
commandHandler = (_, command) => {
|
||||
if (command == "stop")
|
||||
Effect.stop()
|
||||
else
|
||||
Effect.persist(command)
|
||||
},
|
||||
eventHandler = (state, event) => {
|
||||
probe.tell("eventHandler:" + state + ":" + event)
|
||||
if (state == null) event else state + event
|
||||
}).receiveSignal {
|
||||
case (state, RecoveryCompleted) =>
|
||||
probe.tell("onRecoveryCompleted:" + state)
|
||||
}
|
||||
|
||||
"A typed persistent actor with null empty state" must {
|
||||
"persist events and update state" in {
|
||||
val probe = TestProbe[String]()
|
||||
val b = nullState(PersistenceId.ofUniqueId("a"), probe.ref)
|
||||
val ref1 = spawn(b)
|
||||
probe.expectMessage("onRecoveryCompleted:null")
|
||||
ref1 ! "one"
|
||||
probe.expectMessage("eventHandler:null:one")
|
||||
ref1 ! "two"
|
||||
probe.expectMessage("eventHandler:one:two")
|
||||
|
||||
ref1 ! "stop"
|
||||
// wait till ref1 stops
|
||||
probe.expectTerminated(ref1)
|
||||
|
||||
val ref2 = testKit.spawn(b)
|
||||
// eventHandler from reply
|
||||
probe.expectMessage("eventHandler:null:one")
|
||||
probe.expectMessage("eventHandler:one:two")
|
||||
probe.expectMessage("onRecoveryCompleted:onetwo")
|
||||
ref2 ! "three"
|
||||
probe.expectMessage("eventHandler:onetwo:three")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright (C) 2018-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.LoggingTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior.CommandHandler
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
object OptionalSnapshotStoreSpec {
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
|
||||
object AnyCommand extends Command
|
||||
|
||||
final case class State(internal: String = UUID.randomUUID().toString) extends CborSerializable
|
||||
|
||||
case class Event(id: Long = System.currentTimeMillis()) extends CborSerializable
|
||||
|
||||
def persistentBehavior(probe: TestProbe[State], name: String = UUID.randomUUID().toString) =
|
||||
EventSourcedBehavior[Command, Event, State](
|
||||
persistenceId = PersistenceId.ofUniqueId(name),
|
||||
emptyState = State(),
|
||||
commandHandler = CommandHandler.command { _ =>
|
||||
Effect.persist(Event()).thenRun(probe.ref ! _)
|
||||
},
|
||||
eventHandler = {
|
||||
case (_, _) => State()
|
||||
}).snapshotWhen { case _ => true }
|
||||
|
||||
def persistentBehaviorWithSnapshotPlugin(probe: TestProbe[State]) =
|
||||
persistentBehavior(probe).withSnapshotPluginId("pekko.persistence.snapshot-store.local")
|
||||
|
||||
}
|
||||
|
||||
class OptionalSnapshotStoreSpec extends ScalaTestWithActorTestKit(s"""
|
||||
pekko.persistence.publish-plugin-commands = on
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
pekko.persistence.journal.inmem.test-serialization = on
|
||||
|
||||
# snapshot store plugin is NOT defined, things should still work
|
||||
pekko.persistence.snapshot-store.local.dir = "target/snapshots-${classOf[OptionalSnapshotStoreSpec].getName}/"
|
||||
""") with AnyWordSpecLike with LogCapturing {
|
||||
|
||||
import OptionalSnapshotStoreSpec._
|
||||
|
||||
"Persistence extension" must {
|
||||
"initialize properly even in absence of configured snapshot store" in {
|
||||
LoggingTestKit.warn("No default snapshot store configured").expect {
|
||||
val stateProbe = TestProbe[State]()
|
||||
spawn(persistentBehavior(stateProbe))
|
||||
stateProbe.expectNoMessage()
|
||||
}
|
||||
}
|
||||
|
||||
"fail if PersistentActor tries to saveSnapshot without snapshot-store available" in {
|
||||
LoggingTestKit.error("No snapshot store configured").expect {
|
||||
LoggingTestKit.warn("Failed to save snapshot").expect {
|
||||
val stateProbe = TestProbe[State]()
|
||||
val persistentActor = spawn(persistentBehavior(stateProbe))
|
||||
persistentActor ! AnyCommand
|
||||
stateProbe.expectMessageType[State]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"successfully save a snapshot when no default snapshot-store configured, yet PersistentActor picked one explicitly" in {
|
||||
val stateProbe = TestProbe[State]()
|
||||
val persistentActor = spawn(persistentBehaviorWithSnapshotPlugin(stateProbe))
|
||||
persistentActor ! AnyCommand
|
||||
stateProbe.expectMessageType[State]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright (C) 2018-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.TestException
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.SupervisorStrategy
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.testkit.PersistenceTestKitSnapshotPlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import pekko.persistence.typed.scaladsl.EventSourcedBehavior.CommandHandler
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.UUID
|
||||
import scala.concurrent.duration._
|
||||
|
||||
object PerformanceSpec {
|
||||
|
||||
val config =
|
||||
"""
|
||||
pekko.persistence.performance.cycles.load = 100
|
||||
# more accurate throughput measurements
|
||||
#pekko.persistence.performance.cycles.load = 10000
|
||||
# no stash capacity limit
|
||||
pekko.persistence.typed.stash-capacity = 1000000
|
||||
"""
|
||||
|
||||
sealed trait Command
|
||||
|
||||
case object StopMeasure extends Command with Reply
|
||||
|
||||
case class FailAt(sequence: Long) extends Command
|
||||
|
||||
case class CommandWithEvent(evt: String) extends Command
|
||||
|
||||
sealed trait Reply
|
||||
|
||||
case object ExpectedFail extends Reply
|
||||
|
||||
class Measure(numberOfMessages: Int) {
|
||||
private val NanoToSecond = 1000.0 * 1000 * 1000
|
||||
|
||||
private var startTime: Long = 0L
|
||||
private var stopTime: Long = 0L
|
||||
|
||||
def startMeasure(): Unit = {
|
||||
startTime = System.nanoTime
|
||||
}
|
||||
|
||||
def stopMeasure(): Double = {
|
||||
stopTime = System.nanoTime
|
||||
NanoToSecond * numberOfMessages / (stopTime - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
case class Parameters(var persistCalls: Long = 0L, var failAt: Long = -1) {
|
||||
def every(num: Long): Boolean = persistCalls % num == 0
|
||||
|
||||
def shouldFail: Boolean =
|
||||
failAt != -1 && persistCalls % failAt == 0
|
||||
|
||||
def failureWasDefined: Boolean = failAt != -1L
|
||||
}
|
||||
|
||||
def behavior(name: String, probe: TestProbe[Reply])(other: (Command, Parameters) => Effect[String, String]) = {
|
||||
Behaviors
|
||||
.supervise {
|
||||
val parameters = Parameters()
|
||||
EventSourcedBehavior[Command, String, String](
|
||||
persistenceId = PersistenceId.ofUniqueId(name),
|
||||
"",
|
||||
commandHandler = CommandHandler.command {
|
||||
case StopMeasure =>
|
||||
Effect.none.thenRun(_ => probe.ref ! StopMeasure)
|
||||
case FailAt(sequence) =>
|
||||
Effect.none.thenRun(_ => parameters.failAt = sequence)
|
||||
case command => other(command, parameters)
|
||||
},
|
||||
eventHandler = {
|
||||
case (state, _) => state
|
||||
}).receiveSignal {
|
||||
case (_, RecoveryCompleted) =>
|
||||
if (parameters.every(1000)) print("r")
|
||||
}
|
||||
}
|
||||
.onFailure(SupervisorStrategy.restart.withLoggingEnabled(false))
|
||||
}
|
||||
|
||||
def eventSourcedTestPersistenceBehavior(name: String, probe: TestProbe[Reply]) =
|
||||
behavior(name, probe) {
|
||||
case (CommandWithEvent(evt), parameters) =>
|
||||
Effect
|
||||
.persist(evt)
|
||||
.thenRun(_ => {
|
||||
parameters.persistCalls += 1
|
||||
if (parameters.every(1000)) print(".")
|
||||
if (parameters.shouldFail) {
|
||||
probe.ref ! ExpectedFail
|
||||
throw TestException("boom")
|
||||
}
|
||||
})
|
||||
case _ => Effect.none
|
||||
}
|
||||
}
|
||||
|
||||
class PerformanceSpec
|
||||
extends ScalaTestWithActorTestKit(
|
||||
PersistenceTestKitPlugin.config
|
||||
.withFallback(PersistenceTestKitSnapshotPlugin.config)
|
||||
.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.persistence.publish-plugin-commands = on
|
||||
pekko.actor.testkit.typed.single-expect-default = 10s
|
||||
"""))
|
||||
.withFallback(ConfigFactory.parseString(PerformanceSpec.config)))
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import PerformanceSpec._
|
||||
|
||||
val loadCycles = system.settings.config.getInt("pekko.persistence.performance.cycles.load")
|
||||
|
||||
def stressPersistentActor(
|
||||
persistentActor: ActorRef[Command],
|
||||
probe: TestProbe[Reply],
|
||||
failAt: Option[Long],
|
||||
description: String): Unit = {
|
||||
failAt.foreach { persistentActor ! FailAt(_) }
|
||||
val m = new Measure(loadCycles)
|
||||
m.startMeasure()
|
||||
val parameters = Parameters(0, failAt = failAt.getOrElse(-1))
|
||||
(1 to loadCycles).foreach { n =>
|
||||
parameters.persistCalls += 1
|
||||
persistentActor ! CommandWithEvent(s"msg$n")
|
||||
// stash is cleared when exception is thrown so have to wait before sending more commands
|
||||
if (parameters.shouldFail)
|
||||
probe.expectMessage(ExpectedFail)
|
||||
}
|
||||
persistentActor ! StopMeasure
|
||||
probe.expectMessage(100.seconds, StopMeasure)
|
||||
println(f"\nthroughput = ${m.stopMeasure()}%.2f $description per second")
|
||||
}
|
||||
|
||||
def stressEventSourcedPersistentActor(failAt: Option[Long]): Unit = {
|
||||
val probe = TestProbe[Reply]()
|
||||
val name = s"${this.getClass.getSimpleName}-${UUID.randomUUID().toString}"
|
||||
val persistentActor = spawn(eventSourcedTestPersistenceBehavior(name, probe), name)
|
||||
stressPersistentActor(persistentActor, probe, failAt, "persistent events")
|
||||
}
|
||||
|
||||
"An event sourced persistent actor" should {
|
||||
"have some reasonable throughput" in {
|
||||
stressEventSourcedPersistentActor(None)
|
||||
}
|
||||
"have some reasonable throughput under failure conditions" in {
|
||||
stressEventSourcedPersistentActor(Some((loadCycles / 10).toLong))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (C) 2018-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.RecoveryCompleted
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
object PrimitiveStateSpec {
|
||||
|
||||
private val conf = ConfigFactory.parseString(s"""
|
||||
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem"
|
||||
pekko.persistence.journal.inmem.test-serialization = on
|
||||
""")
|
||||
}
|
||||
|
||||
class PrimitiveStateSpec
|
||||
extends ScalaTestWithActorTestKit(PrimitiveStateSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
def primitiveState(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[Int] =
|
||||
EventSourcedBehavior[Int, Int, Int](
|
||||
persistenceId,
|
||||
emptyState = 0,
|
||||
commandHandler = (_, command) => {
|
||||
if (command < 0)
|
||||
Effect.stop()
|
||||
else
|
||||
Effect.persist(command)
|
||||
},
|
||||
eventHandler = (state, event) => {
|
||||
probe.tell("eventHandler:" + state + ":" + event)
|
||||
state + event
|
||||
}).receiveSignal {
|
||||
case (n, RecoveryCompleted) =>
|
||||
probe.tell("onRecoveryCompleted:" + n)
|
||||
}
|
||||
|
||||
"A typed persistent actor with primitive state" must {
|
||||
"persist primitive events and update state" in {
|
||||
val probe = TestProbe[String]()
|
||||
val b = primitiveState(PersistenceId.ofUniqueId("a"), probe.ref)
|
||||
val ref1 = spawn(b)
|
||||
probe.expectMessage("onRecoveryCompleted:0")
|
||||
ref1 ! 1
|
||||
probe.expectMessage("eventHandler:0:1")
|
||||
ref1 ! 2
|
||||
probe.expectMessage("eventHandler:1:2")
|
||||
|
||||
ref1 ! -1
|
||||
probe.expectTerminated(ref1)
|
||||
|
||||
val ref2 = spawn(b)
|
||||
// eventHandler from replay
|
||||
probe.expectMessage("eventHandler:0:1")
|
||||
probe.expectMessage("eventHandler:1:2")
|
||||
probe.expectMessage("onRecoveryCompleted:3")
|
||||
ref2 ! 3
|
||||
probe.expectMessage("eventHandler:3:3")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright (C) 2009-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.persistence.SelectedSnapshot
|
||||
import pekko.persistence.snapshot.SnapshotStore
|
||||
import pekko.persistence.typed.scaladsl.SnapshotMutableStateSpec.MutableState
|
||||
import pekko.persistence.{ SnapshotMetadata => ClassicSnapshotMetadata }
|
||||
import pekko.persistence.{ SnapshotSelectionCriteria => ClassicSnapshotSelectionCriteria }
|
||||
|
||||
import scala.concurrent.Future
|
||||
|
||||
class SlowInMemorySnapshotStore extends SnapshotStore {
|
||||
|
||||
private var state = Map.empty[String, (Any, ClassicSnapshotMetadata)]
|
||||
|
||||
def loadAsync(persistenceId: String, criteria: ClassicSnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = {
|
||||
Future.successful(state.get(persistenceId).map {
|
||||
case (snap, meta) => SelectedSnapshot(meta, snap)
|
||||
})
|
||||
}
|
||||
|
||||
def saveAsync(metadata: ClassicSnapshotMetadata, snapshot: Any): Future[Unit] = {
|
||||
val snapshotState = snapshot.asInstanceOf[MutableState]
|
||||
val value1 = snapshotState.value
|
||||
Thread.sleep(50)
|
||||
val value2 = snapshotState.value
|
||||
// it mustn't have been modified by another command/event
|
||||
if (value1 != value2)
|
||||
Future.failed(new IllegalStateException(s"State changed from $value1 to $value2"))
|
||||
else {
|
||||
// copy to simulate serialization, and subsequent recovery shouldn't get same instance
|
||||
state = state.updated(metadata.persistenceId, (new MutableState(snapshotState.value), metadata))
|
||||
Future.successful(())
|
||||
}
|
||||
}
|
||||
|
||||
override def deleteAsync(metadata: ClassicSnapshotMetadata): Future[Unit] = {
|
||||
state = state.filterNot {
|
||||
case (pid, (_, meta)) => pid == metadata.persistenceId && meta.sequenceNr == metadata.sequenceNr
|
||||
}
|
||||
Future.successful(())
|
||||
}
|
||||
|
||||
override def deleteAsync(persistenceId: String, criteria: ClassicSnapshotSelectionCriteria): Future[Unit] = {
|
||||
state = state.filterNot {
|
||||
case (pid, (_, meta)) => pid == persistenceId && criteria.matches(meta)
|
||||
}
|
||||
Future.successful(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright (C) 2017-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.SnapshotCompleted
|
||||
import pekko.persistence.typed.SnapshotFailed
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object SnapshotMutableStateSpec {
|
||||
|
||||
def conf: Config = PersistenceTestKitPlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
pekko.persistence.snapshot-store.plugin = "slow-snapshot-store"
|
||||
|
||||
slow-snapshot-store.class = "${classOf[SlowInMemorySnapshotStore].getName}"
|
||||
"""))
|
||||
|
||||
sealed trait Command extends CborSerializable
|
||||
case object Increment extends Command
|
||||
final case class GetValue(replyTo: ActorRef[Int]) extends Command
|
||||
|
||||
sealed trait Event extends CborSerializable
|
||||
case object Incremented extends Event
|
||||
|
||||
final class MutableState(var value: Int) extends CborSerializable
|
||||
|
||||
def counter(
|
||||
persistenceId: PersistenceId,
|
||||
probe: ActorRef[String]): EventSourcedBehavior[Command, Event, MutableState] = {
|
||||
EventSourcedBehavior[Command, Event, MutableState](
|
||||
persistenceId,
|
||||
emptyState = new MutableState(0),
|
||||
commandHandler = (state, cmd) =>
|
||||
cmd match {
|
||||
case Increment =>
|
||||
Effect.persist(Incremented)
|
||||
|
||||
case GetValue(replyTo) =>
|
||||
replyTo ! state.value
|
||||
Effect.none
|
||||
},
|
||||
eventHandler = (state, evt) =>
|
||||
evt match {
|
||||
case Incremented =>
|
||||
state.value += 1
|
||||
probe ! s"incremented-${state.value}"
|
||||
state
|
||||
}).receiveSignal {
|
||||
case (_, SnapshotCompleted(meta)) =>
|
||||
probe ! s"snapshot-success-${meta.sequenceNr}"
|
||||
case (_, SnapshotFailed(meta, _)) =>
|
||||
probe ! s"snapshot-failure-${meta.sequenceNr}"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SnapshotMutableStateSpec
|
||||
extends ScalaTestWithActorTestKit(SnapshotMutableStateSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import SnapshotMutableStateSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"A typed persistent actor with mutable state" must {
|
||||
|
||||
"support mutable state by stashing commands while storing snapshot" in {
|
||||
val pid = nextPid()
|
||||
val probe = TestProbe[String]()
|
||||
def snapshotState3: Behavior[Command] =
|
||||
counter(pid, probe.ref).snapshotWhen { (state, _, _) =>
|
||||
state.value == 3
|
||||
}
|
||||
val c = spawn(snapshotState3)
|
||||
|
||||
(1 to 5).foreach { n =>
|
||||
c ! Increment
|
||||
probe.expectMessage(s"incremented-$n")
|
||||
if (n == 3) {
|
||||
// incremented-4 shouldn't be before the snapshot-success-3, because Increment 4 is stashed
|
||||
probe.expectMessage(s"snapshot-success-3")
|
||||
}
|
||||
}
|
||||
|
||||
val replyProbe = TestProbe[Int]()
|
||||
c ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(5)
|
||||
|
||||
// recover new instance
|
||||
val c2 = spawn(snapshotState3)
|
||||
// starting from snapshot 3
|
||||
probe.expectMessage(s"incremented-4")
|
||||
probe.expectMessage(s"incremented-5")
|
||||
c2 ! GetValue(replyProbe.ref)
|
||||
replyProbe.expectMessage(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.serialization.Snapshot
|
||||
import pekko.persistence.testkit.PersistenceTestKitPlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.serialization.Serialization
|
||||
import pekko.serialization.SerializationExtension
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object SnapshotRecoveryWithEmptyJournalSpec {
|
||||
val survivingSnapshotPath = s"target/survivingSnapshotPath-${UUID.randomUUID().toString}"
|
||||
|
||||
def conf: Config = PersistenceTestKitPlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
pekko.persistence.snapshot-store.plugin = "pekko.persistence.snapshot-store.local"
|
||||
pekko.persistence.snapshot-store.local.dir = "${SnapshotRecoveryWithEmptyJournalSpec.survivingSnapshotPath}"
|
||||
pekko.actor.allow-java-serialization = on
|
||||
pekko.actor.warn-about-java-serializer-usage = off
|
||||
"""))
|
||||
|
||||
object TestActor {
|
||||
def apply(name: String, probe: ActorRef[Any]): Behavior[String] = {
|
||||
Behaviors.setup { context =>
|
||||
EventSourcedBehavior[String, String, List[String]](
|
||||
PersistenceId.ofUniqueId(name),
|
||||
Nil,
|
||||
(state, cmd) =>
|
||||
cmd match {
|
||||
case "get" =>
|
||||
probe ! state.reverse
|
||||
Effect.none
|
||||
case _ =>
|
||||
Effect.persist(s"$cmd-${EventSourcedBehavior.lastSequenceNumber(context) + 1}")
|
||||
},
|
||||
(state, event) => event :: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SnapshotRecoveryWithEmptyJournalSpec
|
||||
extends ScalaTestWithActorTestKit(SnapshotRecoveryWithEmptyJournalSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
import SnapshotRecoveryWithEmptyJournalSpec._
|
||||
|
||||
val snapshotsDir: File = new File(survivingSnapshotPath)
|
||||
|
||||
val serializationExtension: Serialization = SerializationExtension(system)
|
||||
|
||||
val persistenceId: String = system.name
|
||||
|
||||
// Prepare a hand made snapshot file as basis for the recovery start point
|
||||
private def createSnapshotFile(sequenceNr: Long, ts: Long, data: Any): Unit = {
|
||||
val snapshotFile = new File(snapshotsDir, s"snapshot-$persistenceId-$sequenceNr-$ts")
|
||||
FileUtils.writeByteArrayToFile(snapshotFile, serializationExtension.serialize(Snapshot(data)).get)
|
||||
}
|
||||
|
||||
val givenSnapshotSequenceNr: Long = 4711L
|
||||
val givenTimestamp: Long = 1000L
|
||||
|
||||
override protected def beforeAll(): Unit = {
|
||||
super.beforeAll()
|
||||
createSnapshotFile(givenSnapshotSequenceNr - 1, givenTimestamp - 1, List("a-1"))
|
||||
createSnapshotFile(givenSnapshotSequenceNr, givenTimestamp, List("b-2", "a-1"))
|
||||
}
|
||||
|
||||
"A persistent actor in a system that only has snapshots and no previous journal activity" must {
|
||||
"recover its state and sequence number starting from the most recent snapshot and use subsequent sequence numbers to persist events to the journal" in {
|
||||
|
||||
val probe = createTestProbe[Any]()
|
||||
val ref = spawn(TestActor(persistenceId, probe.ref))
|
||||
ref ! "c"
|
||||
ref ! "get"
|
||||
probe.expectMessage(List("a-1", "b-2", s"c-${givenSnapshotSequenceNr + 1}"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright (C) 2019-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.state.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.BehaviorInterceptor
|
||||
import pekko.actor.typed.TypedActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
|
||||
|
||||
object DurableStateBehaviorInterceptorSpec {
|
||||
|
||||
def conf: Config = PersistenceTestKitDurableStateStorePlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
|
||||
def testBehavior(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
DurableStateBehavior[String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) =>
|
||||
command match {
|
||||
case _ =>
|
||||
Effect.persist(command).thenRun(newState => probe ! newState)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DurableStateBehaviorInterceptorSpec
|
||||
extends ScalaTestWithActorTestKit(DurableStateBehaviorInterceptorSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import DurableStateBehaviorInterceptorSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"DurableStateBehavior interceptor" must {
|
||||
|
||||
"be possible to combine with another interceptor" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
|
||||
val toUpper = new BehaviorInterceptor[String, String] {
|
||||
override def aroundReceive(
|
||||
ctx: TypedActorContext[String],
|
||||
msg: String,
|
||||
target: BehaviorInterceptor.ReceiveTarget[String]): Behavior[String] = {
|
||||
target(ctx, msg.toUpperCase())
|
||||
}
|
||||
}
|
||||
|
||||
val ref = spawn(Behaviors.intercept(() => toUpper)(testBehavior(pid, probe.ref)))
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("A")
|
||||
probe.expectMessage("BC")
|
||||
}
|
||||
|
||||
"be possible to combine with transformMessages" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testBehavior(pid, probe.ref).transformMessages[String] {
|
||||
case s => s.toUpperCase()
|
||||
})
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("A")
|
||||
probe.expectMessage("BC")
|
||||
}
|
||||
|
||||
"be possible to combine with MDC" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(Behaviors.setup[String] { _ =>
|
||||
Behaviors.withMdc(
|
||||
staticMdc = Map("pid" -> pid.toString),
|
||||
mdcForMessage = (msg: String) => Map("msg" -> msg.toUpperCase())) {
|
||||
testBehavior(pid, probe.ref)
|
||||
}
|
||||
})
|
||||
|
||||
ref ! "a"
|
||||
ref ! "bc"
|
||||
probe.expectMessage("a")
|
||||
probe.expectMessage("bc")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Copyright (C) 2021-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.state.scaladsl
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.Done
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.ActorContext
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.serialization.jackson.CborSerializable
|
||||
|
||||
import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
|
||||
|
||||
object DurableStateBehaviorReplySpec {
|
||||
def conf: Config = PersistenceTestKitDurableStateStorePlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
|
||||
sealed trait Command[ReplyMessage] extends CborSerializable
|
||||
final case class IncrementWithConfirmation(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
final case class IncrementReplyLater(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
final case class ReplyNow(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
final case class GetValue(replyTo: ActorRef[State]) extends Command[State]
|
||||
final case class DeleteWithConfirmation(replyTo: ActorRef[Done]) extends Command[Done]
|
||||
case object Increment extends Command[Nothing]
|
||||
case class IncrementBy(by: Int) extends Command[Nothing]
|
||||
|
||||
final case class State(value: Int) extends CborSerializable
|
||||
|
||||
def counter(persistenceId: PersistenceId): Behavior[Command[_]] =
|
||||
Behaviors.setup(ctx => counter(ctx, persistenceId))
|
||||
|
||||
def counter(ctx: ActorContext[Command[_]], persistenceId: PersistenceId): DurableStateBehavior[Command[_], State] = {
|
||||
DurableStateBehavior.withEnforcedReplies[Command[_], State](
|
||||
persistenceId,
|
||||
emptyState = State(0),
|
||||
commandHandler = (state, command) =>
|
||||
command match {
|
||||
|
||||
case IncrementWithConfirmation(replyTo) =>
|
||||
Effect.persist(state.copy(value = state.value + 1)).thenReply(replyTo)(_ => Done)
|
||||
|
||||
case IncrementReplyLater(replyTo) =>
|
||||
Effect
|
||||
.persist(state.copy(value = state.value + 1))
|
||||
.thenRun((_: State) => ctx.self ! ReplyNow(replyTo))
|
||||
.thenNoReply()
|
||||
|
||||
case ReplyNow(replyTo) =>
|
||||
Effect.reply(replyTo)(Done)
|
||||
|
||||
case GetValue(replyTo) =>
|
||||
Effect.reply(replyTo)(state)
|
||||
|
||||
case DeleteWithConfirmation(replyTo) =>
|
||||
Effect.delete[State]().thenReply(replyTo)(_ => Done)
|
||||
|
||||
case _ => ???
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DurableStateBehaviorReplySpec
|
||||
extends ScalaTestWithActorTestKit(DurableStateBehaviorReplySpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import DurableStateBehaviorReplySpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"A typed persistent actor with commands that are expecting replies" must {
|
||||
|
||||
"persist state thenReply" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[Done]()
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
c ! IncrementWithConfirmation(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
probe.expectMessage(Done)
|
||||
}
|
||||
|
||||
"persist state thenReply later" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val probe = TestProbe[Done]()
|
||||
c ! IncrementReplyLater(probe.ref)
|
||||
probe.expectMessage(Done)
|
||||
}
|
||||
|
||||
"reply to query command" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val updateProbe = TestProbe[Done]()
|
||||
c ! IncrementWithConfirmation(updateProbe.ref)
|
||||
|
||||
val queryProbe = TestProbe[State]()
|
||||
c ! GetValue(queryProbe.ref)
|
||||
queryProbe.expectMessage(State(1))
|
||||
}
|
||||
|
||||
"delete state thenReply" in {
|
||||
val c = spawn(counter(nextPid()))
|
||||
val updateProbe = TestProbe[Done]()
|
||||
c ! IncrementWithConfirmation(updateProbe.ref)
|
||||
updateProbe.expectMessage(Done)
|
||||
|
||||
val deleteProbe = TestProbe[Done]()
|
||||
c ! DeleteWithConfirmation(deleteProbe.ref)
|
||||
deleteProbe.expectMessage(Done)
|
||||
|
||||
val queryProbe = TestProbe[State]()
|
||||
c ! GetValue(queryProbe.ref)
|
||||
queryProbe.expectMessage(State(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright (C) 2021-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.state.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
|
||||
|
||||
object DurableStateBehaviorTimersSpec {
|
||||
|
||||
def conf: Config = PersistenceTestKitDurableStateStorePlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
|
||||
def testBehavior(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
Behaviors.withTimers { timers =>
|
||||
DurableStateBehavior[String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) =>
|
||||
command match {
|
||||
case "scheduled" =>
|
||||
probe ! "scheduled"
|
||||
Effect.none
|
||||
case "cmd-0" =>
|
||||
timers.startSingleTimer("key", "scheduled", Duration.Zero)
|
||||
Effect.none
|
||||
case _ =>
|
||||
timers.startSingleTimer("key", "scheduled", Duration.Zero)
|
||||
Effect.persist(command).thenRun(_ => probe ! command)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
def testTimerFromSetupBehavior(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup { _ =>
|
||||
Behaviors.withTimers { timers =>
|
||||
timers.startSingleTimer("key", "scheduled", Duration.Zero)
|
||||
|
||||
DurableStateBehavior[String, String](
|
||||
persistenceId,
|
||||
emptyState = "",
|
||||
commandHandler = (_, command) =>
|
||||
command match {
|
||||
case "scheduled" =>
|
||||
probe ! "scheduled"
|
||||
Effect.none
|
||||
case _ =>
|
||||
Effect.persist(command).thenRun(_ => probe ! command)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DurableStateBehaviorTimersSpec
|
||||
extends ScalaTestWithActorTestKit(DurableStateBehaviorTimersSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
import DurableStateBehaviorTimersSpec._
|
||||
|
||||
val pidCounter = new AtomicInteger(0)
|
||||
private def nextPid(): PersistenceId = PersistenceId.ofUniqueId(s"c${pidCounter.incrementAndGet()})")
|
||||
|
||||
"DurableStateBehavior withTimers" must {
|
||||
|
||||
"be able to schedule message" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testBehavior(pid, probe.ref))
|
||||
|
||||
ref ! "cmd-0"
|
||||
probe.expectMessage("scheduled")
|
||||
}
|
||||
|
||||
"not discard timer msg due to stashing" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testBehavior(pid, probe.ref))
|
||||
|
||||
ref ! "cmd-1"
|
||||
probe.expectMessage("cmd-1")
|
||||
probe.expectMessage("scheduled")
|
||||
}
|
||||
|
||||
"be able to schedule message from setup" in {
|
||||
val probe = createTestProbe[String]()
|
||||
val pid = nextPid()
|
||||
val ref = spawn(testTimerFromSetupBehavior(pid, probe.ref))
|
||||
|
||||
probe.expectMessage("scheduled")
|
||||
|
||||
(1 to 20).foreach { n =>
|
||||
ref ! s"cmd-$n"
|
||||
}
|
||||
probe.receiveMessages(20)
|
||||
|
||||
// start new instance that is likely to stash the timer message while replaying
|
||||
spawn(testTimerFromSetupBehavior(pid, probe.ref))
|
||||
probe.expectMessage("scheduled")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright (C) 2009-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.state.scaladsl
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl.LogCapturing
|
||||
import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
|
||||
import pekko.actor.testkit.typed.scaladsl.TestProbe
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.actor.typed.scaladsl.Behaviors
|
||||
import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import pekko.persistence.typed.state.RecoveryCompleted
|
||||
|
||||
object DurableStateRevisionSpec {
|
||||
|
||||
def conf: Config = PersistenceTestKitDurableStateStorePlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
|
||||
}
|
||||
|
||||
class DurableStateRevisionSpec
|
||||
extends ScalaTestWithActorTestKit(DurableStateRevisionSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
private def behavior(pid: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
Behaviors.setup(ctx =>
|
||||
DurableStateBehavior[String, String](
|
||||
pid,
|
||||
"",
|
||||
(state, command) =>
|
||||
state match {
|
||||
case "stashing" =>
|
||||
command match {
|
||||
case "unstash" =>
|
||||
probe ! s"${DurableStateBehavior.lastSequenceNumber(ctx)} unstash"
|
||||
Effect.persist("normal").thenUnstashAll()
|
||||
case _ =>
|
||||
Effect.stash()
|
||||
}
|
||||
case _ =>
|
||||
command match {
|
||||
case "cmd" =>
|
||||
probe ! s"${DurableStateBehavior.lastSequenceNumber(ctx)} onCommand"
|
||||
Effect
|
||||
.persist("state")
|
||||
.thenRun(_ => probe ! s"${DurableStateBehavior.lastSequenceNumber(ctx)} thenRun")
|
||||
case "stash" =>
|
||||
probe ! s"${DurableStateBehavior.lastSequenceNumber(ctx)} stash"
|
||||
Effect.persist("stashing")
|
||||
case "snapshot" =>
|
||||
Effect.persist("snapshot")
|
||||
}
|
||||
}).receiveSignal {
|
||||
case (_, RecoveryCompleted) =>
|
||||
probe ! s"${DurableStateBehavior.lastSequenceNumber(ctx)} onRecoveryComplete"
|
||||
})
|
||||
|
||||
"The revision number" must {
|
||||
|
||||
"be accessible in the handlers" in {
|
||||
val probe = TestProbe[String]()
|
||||
val ref = spawn(behavior(PersistenceId.ofUniqueId("pid-1"), probe.ref))
|
||||
probe.expectMessage("0 onRecoveryComplete")
|
||||
|
||||
ref ! "cmd"
|
||||
probe.expectMessage("0 onCommand")
|
||||
probe.expectMessage("1 thenRun")
|
||||
|
||||
ref ! "cmd"
|
||||
probe.expectMessage("1 onCommand")
|
||||
probe.expectMessage("2 thenRun")
|
||||
|
||||
testKit.stop(ref)
|
||||
probe.expectTerminated(ref)
|
||||
|
||||
// and during recovery
|
||||
val ref2 = spawn(behavior(PersistenceId.ofUniqueId("pid-1"), probe.ref))
|
||||
probe.expectMessage("2 onRecoveryComplete")
|
||||
|
||||
ref2 ! "cmd"
|
||||
probe.expectMessage("2 onCommand")
|
||||
probe.expectMessage("3 thenRun")
|
||||
}
|
||||
|
||||
"be available while unstashing" in {
|
||||
val probe = TestProbe[String]()
|
||||
val ref = spawn(behavior(PersistenceId.ofUniqueId("pid-2"), probe.ref))
|
||||
probe.expectMessage("0 onRecoveryComplete")
|
||||
|
||||
ref ! "stash"
|
||||
ref ! "cmd"
|
||||
ref ! "cmd"
|
||||
ref ! "cmd"
|
||||
ref ! "unstash"
|
||||
probe.expectMessage("0 stash")
|
||||
probe.expectMessage("1 unstash")
|
||||
probe.expectMessage("2 onCommand")
|
||||
probe.expectMessage("3 thenRun")
|
||||
probe.expectMessage("3 onCommand")
|
||||
probe.expectMessage("4 thenRun")
|
||||
probe.expectMessage("4 onCommand")
|
||||
probe.expectMessage("5 thenRun")
|
||||
}
|
||||
|
||||
"not fail when snapshotting" in {
|
||||
val probe = TestProbe[String]()
|
||||
val ref = spawn(behavior(PersistenceId.ofUniqueId("pid-3"), probe.ref))
|
||||
probe.expectMessage("0 onRecoveryComplete")
|
||||
|
||||
ref ! "cmd"
|
||||
ref ! "snapshot"
|
||||
ref ! "cmd"
|
||||
|
||||
probe.expectMessage("0 onCommand") // first command
|
||||
probe.expectMessage("1 thenRun")
|
||||
probe.expectMessage("2 onCommand") // second command
|
||||
probe.expectMessage("3 thenRun")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (C) 2021-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.state.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.TestKitSettings
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
|
||||
|
||||
object NullEmptyStateSpec {
|
||||
|
||||
def conf: Config = PersistenceTestKitDurableStateStorePlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
}
|
||||
|
||||
class NullEmptyStateSpec
|
||||
extends ScalaTestWithActorTestKit(NullEmptyStateSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
implicit val testSettings: TestKitSettings = TestKitSettings(system)
|
||||
|
||||
def nullState(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[String] =
|
||||
DurableStateBehavior[String, String](
|
||||
persistenceId,
|
||||
emptyState = null,
|
||||
commandHandler = (state, command) => {
|
||||
if (command == "stop")
|
||||
Effect.stop()
|
||||
else if (state == null)
|
||||
Effect.persist(command).thenReply(probe)(newState => newState)
|
||||
else
|
||||
Effect.persist(s"$state:$command").thenReply(probe)(newState => newState)
|
||||
})
|
||||
|
||||
"A typed persistent actor with null empty state" must {
|
||||
"persist and update state" in {
|
||||
val probe = TestProbe[String]()
|
||||
val b = nullState(PersistenceId.ofUniqueId("a"), probe.ref)
|
||||
val ref1 = spawn(b)
|
||||
ref1 ! "one"
|
||||
probe.expectMessage("one")
|
||||
ref1 ! "two"
|
||||
probe.expectMessage("one:two")
|
||||
ref1 ! "stop"
|
||||
probe.expectTerminated(ref1)
|
||||
|
||||
val ref2 = spawn(b)
|
||||
ref2 ! "three"
|
||||
probe.expectMessage("one:two:three")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright (C) 2021-2022 Lightbend Inc. <https://www.lightbend.com>
|
||||
*/
|
||||
|
||||
package org.apache.pekko.persistence.typed.state.scaladsl
|
||||
|
||||
import org.apache.pekko
|
||||
import pekko.actor.testkit.typed.scaladsl._
|
||||
import pekko.actor.typed.ActorRef
|
||||
import pekko.actor.typed.Behavior
|
||||
import pekko.persistence.typed.PersistenceId
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.scalatest.wordspec.AnyWordSpecLike
|
||||
|
||||
import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
|
||||
|
||||
object PrimitiveStateSpec {
|
||||
|
||||
def conf: Config = PersistenceTestKitDurableStateStorePlugin.config.withFallback(ConfigFactory.parseString(s"""
|
||||
pekko.loglevel = INFO
|
||||
"""))
|
||||
}
|
||||
|
||||
class PrimitiveStateSpec
|
||||
extends ScalaTestWithActorTestKit(PrimitiveStateSpec.conf)
|
||||
with AnyWordSpecLike
|
||||
with LogCapturing {
|
||||
|
||||
def primitiveState(persistenceId: PersistenceId, probe: ActorRef[String]): Behavior[Int] =
|
||||
DurableStateBehavior[Int, Int](
|
||||
persistenceId,
|
||||
emptyState = 0,
|
||||
commandHandler = (state, command) => {
|
||||
if (command < 0)
|
||||
Effect.stop()
|
||||
else
|
||||
Effect.persist(state + command).thenReply(probe)(newState => newState.toString)
|
||||
})
|
||||
|
||||
"A typed persistent actor with primitive state" must {
|
||||
"persist primitive state and update" in {
|
||||
val probe = TestProbe[String]()
|
||||
val b = primitiveState(PersistenceId.ofUniqueId("a"), probe.ref)
|
||||
val ref1 = spawn(b)
|
||||
ref1 ! 1
|
||||
probe.expectMessage("1")
|
||||
ref1 ! 2
|
||||
probe.expectMessage("3")
|
||||
|
||||
ref1 ! -1
|
||||
probe.expectTerminated(ref1)
|
||||
|
||||
val ref2 = spawn(b)
|
||||
ref2 ! 3
|
||||
probe.expectMessage("6")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue