Part 1 of 4Architectural trade-offs building EDA with Cluster Sharding: Effect vs Pekko

One Dad per chat: virtual actors in Effect and Pekko

Part 1 of rebuilding Effect TS Cluster's DadBot with Pekko Cluster Sharding: why the naive webhook handler repeats jokes, and what changes when every chat gets its own sharded entity.

Effect is publishing a series on Effect Cluster. It builds DadBot, a chat bot that answers messages with dad jokes and makes one promise: it never tells the same joke twice in the same chat. Over four parts the bot grows from a webhook handler into a sharded, durable, idempotent system.

I’ve spent years building systems like this on Akka, now Apache Pekko, so I’m rebuilding DadBot on Pekko one part at a time. Each post follows the matching Effect post: the same app, the same failure cases, and a look at where the two frameworks made different architectural choices. This series is about those choices and their trade-offs, not about which framework is better.

The Pekko code is in dadbot-pekko. Every part is a runnable sbt module that plays the same conversation as Effect’s Program.ts.

The race, on the JVM

Effect’s part 1 starts with the handler everyone writes first: load the chat’s joke history, generate a pun that avoids it, save the history, reply. Here it is next to a line-for-line Scala version:

Effect (TypeScript)Effect's part 1 sketch
async function onMessage(chatId: string, message: string) {
  const told = await db.getJokesTold(chatId)
  const joke = await punGenerator.generate(message, {
    exclude: told,
  })
  await db.setJokesTold(chatId, [...told, joke])
  await chat.reply(chatId, joke)
}
Pekko (Scala)part1-actors/NaiveHandler.scala
final class NaiveHandler(db: JokeDb, puns: PunGenerator, chat: Chat)(using ExecutionContext):
  def onMessage(chatId: String, message: ChatMessage): Future[Unit] =
    for
      told <- db.getJokesTold(chatId)
      joke <- puns.generate(Seq(message), told)
      _ <- db.setJokesTold(chatId, told + joke)
      _ <- chat.reply(chatId, joke)
    yield ()

The pun generator takes two seconds, playing the part of an LLM call. Sebastian asks why the test is flaky, and a second later Mattia says it’s flaky for him too. Both handlers read the history before either one saves it:

$ sbt part1-actors/run   # naive handler
#engineering · Sebastian: Why is this test flaky?
#engineering · Mattia: It's flaky for me too
#random · Tim: Is CI down?
#engineering · DadBot: I'd tell you a UDP joke, but you might not get it.
#engineering · DadBot: I'd tell you a UDP joke, but you might not get it.
#random · DadBot: I'd tell you a UDP joke, but you might not get it.
Repeated joke in #engineering!

Nothing here is specific to JavaScript or the JVM. A read-modify-write that spans an await (or a flatMap) is a race on any runtime. The obvious fix is a lock per chat. Effect’s post covers why that gets ugly: you need a distributed lock, a crashed server holds a chat hostage until the lock times out, and messages that pile up behind the lock each get their own reply.

One Dad per chat

The actor model fixes this differently. Instead of guarding shared state, give each chat its own Dad: something with an address, a mailbox and private memory that handles one message at a time. Nobody else can touch his joke history, so there’s nothing to lock.

Effect’s part 1 stops at the concepts. The code below is the entity shape from Effect’s part 2, without the batching, next to the Pekko Dad from this part:

Effect (TypeScript)Dad.ts (adapted from Effect's part 2)
export const Dad = Entity.make("Dad", [
  Rpc.make("NewMessage", {
    payload: { from: Schema.String, text: Schema.String },
  }),
])

export const DadLayer = Dad.toLayer(
  Effect.gen(function* () {
    const address = yield* Entity.CurrentAddress
    const punGenerator = yield* PunGenerator
    const chat = yield* Chat
    const told = new Set<string>()

    return {
      // Runs with concurrency 1: the next NewMessage waits
      // until this whole effect, pun included, is done.
      NewMessage: ({ payload }) =>
        Effect.gen(function* () {
          const joke = yield* punGenerator.generate([payload], { told })
          told.add(joke)
          yield* chat.reply(address.entityId, joke)
        }),
    }
  }),
)
Pekko (Scala)part1-actors/Dad.scala
object Dad:
  sealed trait Command
  final case class NewMessage(from: String, text: String) extends Command
  private final case class JokeReady(joke: String) extends Command
  private final case class JokeFailed(cause: Throwable) extends Command

  val TypeKey: EntityTypeKey[Command] = EntityTypeKey[Command]("Dad")

  def apply(chatId: String, puns: PunGenerator, chat: Chat): Behavior[Command] =
    Behaviors.setup { ctx =>
      Behaviors.withStash(capacity = 100) { stash =>
        def listening(told: Set[String]): Behavior[Command] =
          Behaviors.receiveMessagePartial { case NewMessage(from, text) =>
            ctx.pipeToSelf(puns.generate(Seq(ChatMessage(from, text)), told)) {
              case Success(joke)  => JokeReady(joke)
              case Failure(cause) => JokeFailed(cause)
            }
            thinking(told)
          }

        def thinking(told: Set[String]): Behavior[Command] =
          Behaviors.receiveMessage {
            case JokeReady(joke) =>
              chat.reply(chatId, joke)
              stash.unstashAll(listening(told + joke))
            case JokeFailed(cause) =>
              ctx.log.warn("Dad for {} lost his train of thought: {}", chatId, cause.getMessage)
              stash.unstashAll(listening(told))
            case message: NewMessage =>
              stash.stash(message)
              Behaviors.same
          }

        listening(Set.empty)
      }
    }
New to Pekko? Reading Dad line by line

If you haven’t used Pekko (or Akka) before, the Scala side packs a lot into a few lines. Here’s what each piece does.

The protocol. Command is everything Dad understands. In typed Pekko an actor is a Behavior[Command], so the compiler rejects any other message type. NewMessage is the public message. JokeReady and JokeFailed are private: they belong to the type, but only Dad can create them, because he sends them to himself.

The entity type. EntityTypeKey[Command]("Dad") names this kind of entity in the cluster. Every node must use the same name, and the type parameter is what makes references to a Dad typed.

Behaviors.setup runs once, when a Dad actually starts, and hands you the ActorContext (ctx): logging, the actor’s own reference, pipeToSelf, timers.

Behaviors.withStash(capacity = 100) gives Dad a buffer for messages he can’t handle yet. The capacity is a hard limit: the 101st stashed message fails the actor. Sharding starts a fresh Dad on the next message, with an empty history.

A behavior is a state. listening and thinking are functions that return a Behavior. After each message the actor returns the behavior for the next one, so changing state means returning a different function. The joke history is a parameter, not a variable: “updating” it means returning listening(told + joke). Nothing in Dad is mutable.

receiveMessagePartial handles only the cases you match. Anything else, such as a stray JokeReady while Dad is listening, is marked unhandled and logged. thinking uses receiveMessage, which must handle every message.

pipeToSelf enforces the rule every Pekko newcomer learns the hard way: never touch actor state from a Future callback, because the callback runs on another thread. pipeToSelf turns the future’s result into a message and puts it in Dad’s own mailbox, so the pun is handled like any other message, one at a time.

stash.unstashAll(next) switches to next and replays the stashed messages into it, in arrival order. Behaviors.same means “keep the current behavior”.

Put together, Dad is a two-state machine:

listening ──NewMessage──► thinking ──JokeReady / JokeFailed──► listening
                            ▲   │
                            └───┘  NewMessage: stash it

Why a cluster for one node? Sharding only places entities on cluster members, so even this single-node run sets pekko.actor.provider = cluster and lists the node itself as its only seed node. The code doesn’t change when part 2 adds more nodes.

One thing Dad doesn’t do yet is keep his history anywhere but memory. When he is passivated after being idle, or crashes, told is gone. Making that durable comes later in the series.

The two look alike at first glance: a protocol (Rpc.make / a sealed Command trait), private state (told), and a handler that generates a pun and replies. The interesting difference is what “one message at a time” means.

If the pun generator fails, Dad logs it, drops that message and moves on to the stashed ones. Nobody retries it yet. What should happen to a message that was received but never answered is the subject of part 3.

That’s more code on the Pekko side. You also get an explicit choice of what to do with messages that arrive while Dad is busy: stash them, drop them, or collect them into a batch. Part 2 needs exactly that, because Dad starts waiting for the chat to go quiet and then makes one joke about everything that was said.

Messaging by address

The second half of the actor model is location transparency. The webhook doesn’t know or care where #engineering’s Dad lives. It sends to an address, and the cluster finds him, starting him if needed:

Effect (TypeScript)Effect
const makeDad = yield* Dad.client
const dad = makeDad("#engineering")

yield* dad.NewMessage(
  { from: "Sebastian", text: "Why is this test flaky?" },
  { discard: true },
)
Pekko (Scala)Pekko
ClusterSharding(system).init(Entity(Dad.TypeKey)(ctx => Dad(ctx.entityId, puns, chat)))

val dad = ClusterSharding(system).entityRefFor(Dad.TypeKey, "#engineering")

dad ! Dad.NewMessage("Sebastian", "Why is this test flaky?")

Both are virtual actors: a Dad exists conceptually forever, is started on his first message, and is passivated after being idle. In Pekko, ClusterSharding.init registers the entity type on a node, and entityRefFor gives you a reference by type and id. Effect’s Dad.client does the same with a typed RPC client generated from the protocol. Pekko’s ! is fire-and-forget, the equivalent of Effect’s { discard: true }.

Same conversation, sharded Dads:

$ sbt part1-actors/run   # sharded Dad
#engineering · Sebastian: Why is this test flaky?
Dad for #engineering has entered the chat
#engineering · Mattia: It's flaky for me too
#random · Tim: Is CI down?
Dad for #random has entered the chat
#engineering · DadBot: I'd tell you a UDP joke, but you might not get it.
#random · DadBot: I'd tell you a UDP joke, but you might not get it.
#engineering · DadBot: It's not flaky. It's just going through a phase.
No chat heard the same joke twice.

Mattia’s message arrives while Dad is still thinking about Sebastian’s, gets stashed, and gets its own, different joke. #random has its own Dad with his own history, so Tim hears the UDP joke too, and that’s fine.

Same ideas, different names

Both frameworks split the cluster into a fixed number of shards and hash each entity id to a shard. Ownership is tracked per shard, not per entity. The vocabulary differs:

Concepts
AspectEffectPekko
Process hosting entitiesRunnerNode (an ActorSystem in the cluster), hosting a ShardRegion per entity type
Bucket of entitiesShard (shardsPerGroup = 300 by default)Shard (number-of-shards = 1000 by default)
Entity definitionEntity.make(type, rpcs) + toLayer(handlers)EntityTypeKey[Command] + Behavior[Command]
Who decides shard placementEvery runner, using the same hash ring (part 2)One ShardCoordinator, a cluster singleton (part 2)

And the first trade-offs, before a second node even exists:

Trade-offs in part 1
AspectEffectPekko
Sequential handlingImplicit: concurrency 1 covers the whole handler effect, async work includedExplicit: one message at a time, async work comes back as a message, waiting states are yours to model (stash)
Entity stateMutable variables in the layer's scopeImmutable values carried by behavior functions
ProtocolSchema-typed RPCs, serializable by constructionPlain case classes; serialization is configured separately (part 2)
PassivationAfter entityMaxIdleTime (1 minute by default)Idle timeout (120 s by default), plus limit-based strategies such as LRU with an active-entity limit
Backlog boundentityMailboxCapacity = 4096, then MailboxFullUnbounded mailbox by default; here the stash capacity (100) is the bound
SendingGenerated typed client, discard: true for fire-and-forgetEntityRef with ! (tell) or ask

What’s next

Everything so far runs on one node, so “cluster” is a promise, not a fact. Part 2 adds two more nodes and the quiet-period batching. Then it gets to the biggest architectural difference between the two: Effect runners agree on shard ownership without a leader, using a shared runner table, a hash ring and shard locks in the database, while Pekko elects a ShardCoordinator singleton and relies on cluster membership and a split brain resolver. Part 2 also sets up Pekko Management, so we can watch members join and shards move while we kill a node.

The code for this part is in part1-actors. Run sbt part1-actors/run to watch the race and the fix.