Key value entities¶
A key value entity is a piece of state, addressed by an id, that stores only its latest value. A command handler replaces the value with a new one, and the previous value is gone. There is no history and no event handler: what is stored is what the entity is.
A key value entity has the same hosting guarantees as an event sourced entity. Each id lives in one place in the service's cluster, receives one command at a time, and survives restarts because its value is persisted before the reply is sent. The difference is only in what reaches storage.
Choosing between key value and event sourced¶
Choose a key value entity when nothing ever needs to know how the value came to be: a user's preferences, a configuration record, the latest reading from a device. Choose an event sourced entity when something does:
| Need | Key value | Event sourced |
|---|---|---|
| Read and replace the current value | yes | yes |
| An audit trail of every change | no | yes |
| A consumer that reacts to every change | no; intermediate values can be skipped | yes, each event exactly once in order |
| A view that counts or sums changes | no | yes |
| A view of the current value | yes | yes |
| Simplest code | yes |
The consumer row is the one that decides most cases. A view or consumer reading a key value entity is guaranteed to see the latest value, but not every value in between, because the store keeps no history to replay. That is right for a projection of what is, and wrong for anything that counts or audits.
Writing the entity¶
A key value entity declares its empty state and its handlers. A command replaces the state with
updateState and then chooses a reply. A query only replies. The sample below keeps a traveller's
preferences:
Scala
package planner.application
import com.thinkmorestupidless.ankka.core.*
import com.thinkmorestupidless.ankka.core.Serializers.given
import com.thinkmorestupidless.ankka.sdk.*
import planner.domain.Preferences
/** A user's stated preferences, so an agent can be given context it did not ask for. */
final class PreferencesEntity(context: KeyValueEntityContext) extends KeyValueEntity[Preferences]:
private val userId: String = context.entityId
def emptyState: Preferences = Preferences.empty(userId)
def set(preferences: Preferences): Effect[Done] =
if preferences.maxBudget < 0 then effects.error("budget cannot be negative")
else effects.updateState(preferences.copy(userId = userId)).thenReply(_ => Done)
def addLike(activity: String): Effect[Preferences] =
if activity.isBlank then effects.error("an activity needs a name")
else
effects
.updateState(currentState.copy(likes = (currentState.likes :+ activity).distinct))
.thenReplyState
def get: ReadOnlyEffect[Preferences] = effects.reply(currentState)
object PreferencesEntity
extends KeyValueEntity.Companion[PreferencesEntity, Preferences](
componentId = ComponentId("preferences"),
stateSerializer = Codecs.serializer[Preferences]("preferences")
):
def create(context: KeyValueEntityContext) = new PreferencesEntity(context)
val set = command("set")(_.set)
val addLike = command("add-like")(_.addLike)
val get = query("get")(_.get)
Python
"""Where the notifier records checkouts: a key value entity per cart holding when it happened."""
from __future__ import annotations
from dataclasses import dataclass
from ankka import DONE, Done, json_codec
from ankka.effects.key_value import KeyValueEffect, KeyValueReadOnlyEffect
from ankka.event_sourced_entity import command, query
from ankka.key_value_entity import KeyValueEntity
@dataclass(frozen=True)
class CheckoutRecord:
cartId: str
at: int = 0
notified: bool = False
class CheckoutLog(KeyValueEntity[CheckoutRecord]):
component_id = "checkout-log"
state_codec = json_codec(CheckoutRecord, "checkout-record")
def empty_state(self) -> CheckoutRecord:
return CheckoutRecord(self.entity_id)
@command("record")
def record(self, at: int) -> KeyValueEffect[CheckoutRecord, Done]:
return self.effects.update_state(CheckoutRecord(self.entity_id, at, True)).then_reply(lambda _: DONE)
@query("get")
def get(self) -> KeyValueReadOnlyEffect[CheckoutRecord, CheckoutRecord]:
return self.effects.reply(self.state)
The shape matches an event sourced entity's, and so do the rules. Handlers are declared with
command or query under a wire name that is separate from the method name. A query must return a
read-only effect, so it cannot change the state. In Scala the current value is currentState; in Python
it is self.state. The id is context.entityId in Scala and self.entity_id in Python.
Effects¶
| Scala | Python | Meaning |
|---|---|---|
effects.updateState(s) |
self.effects.update_state(s) |
Replace the stored value with s, then choose a reply. |
.thenReply(s => value) |
.then_reply(lambda s: value) |
Reply with a value computed from the new state. |
.thenReplyState |
.then_reply_state() |
Reply with the new state. |
.thenNoReply |
.then_no_reply() |
Update and reply with nothing. |
.expireAfter(duration) |
.expire_after(timedelta) |
Delete the entity once duration passes with no further update. |
effects.deleteEntity() |
self.effects.delete_entity() |
Delete the stored value; then choose a reply. |
effects.reply(value) |
self.effects.reply(value) |
Reply without changing anything. |
effects.error(message, code) |
self.effects.error(message, code) |
Refuse the command; nothing changes. The code defaults to BadRequest. |
A handler that replies without calling updateState leaves the value exactly as it was, and nothing is
written.
After a deletion the id starts again from the empty state on its next command.
Registering and calling¶
Register the entity like any other component, and call it through the component client:
Scala
val service = Ankka.service.register(PreferencesEntity.descriptor).start()
val preferences =
componentClient.forKeyValueEntity(EntityId("user-1")).call(PreferencesEntity.get).invoke()
Python
service = Ankka.service().register(CheckoutLog)
record = await client.for_key_value_entity("checkout-log", "c1").call("get").invoke(reply=CheckoutRecord)
Projecting a key value entity¶
A view or consumer can read a key value entity's changes. In Scala the source is
ChangeSource.stateOf(PreferencesEntity); in Python it is source = CheckoutLog. Each change delivered
is the entity's whole new value, not a difference. See Views and Consumers.
Testing¶
KeyValueEntityTestKit in Scala and KeyValueTestKit in Python run a handler with no runtime, apply the
new state, and show the reply:
Scala
val kit = KeyValueEntityTestKit.of(PreferencesEntity, "user-1")
val result = kit.call(PreferencesEntity.addLike)("hiking")
assertEquals(result.replyValue.likes, List("hiking"))
assert(result.changed)
Python
kit = KeyValueTestKit.of(CheckoutLog, "c1")
kit.call("record", 1_700_000_000_000)
assert kit.call("get").reply.notified
See Testing for running the whole service against a real database.