Files
NLnetLabs-krill/doc/development/04_es_krill.md
T
Tim BruijnzeelsandGitHub 3b139e4a9f Clippy and spelling fixes (#471)
* Fix clippy warnings.
* Fix spelling - as agreed use american spelling in code (like RFCs do as well).
2021-04-15 14:42:32 +02:00

20 KiB

Event Sourcing in Krill

If you search for it you will find that there are many blog posts about how people made Event Sourcing and CQRS work for their project. There are also many libraries, for many languages, that provide frameworks. Some of these libraries will have you believe that their implementation is the right way of doing this... but we believe that there is more than one way to happiness. So, in Krill we have chosen to use our own local implementation. This lets us use the concepts that we need, but it allows us the flexibility to adapt it to our specific needs, and avoids the complication from concepts that we do not need.

Krill uses its own event sourcing library code which can be found under src/commons/eventsourcing. We will describe the concepts and code here, and include code (snippets). But, obviously, the real code is leading - so be sure to check it and fix this documentation if the two should disagree!

KeyValueStore and JSON

Before we delve in to the Krill eventsourcing code, we should talk a bit about storage. Krill stores all values in a KeyValueStore, which is currently implemented as an enum using a disk based back-end as the only current implementation. The idea is that this will be extended in future with other implementations, perhaps sled, tikv or some redis based store.

It may be good to use an enum, because if we have all possible implementations in our own code then we don't need generics - which have a way of trickling up and causing long compilation times.

In any case, the KeyValueStore (src/commons/eventsourcing/kv.rs) expects that we present a KeyStoreKey and save or retrieve values. The key supports 'scopes' which can be useful for categorizing values according to their 'aggregate'. Scopes can also use / characters to present sub-scopes, or sub-dirs in the the disk based implementation:

#[derive(Clone, Debug)]
pub struct KeyStoreKey {
    scope: Option<String>,
    name: String,
}

We use serde json (de-)serialization for all types that need to be stored. The following trait is used as a convenient shorthand:

pub trait Storable: Clone + Serialize + DeserializeOwned + Sized + 'static {}
impl<T: Clone + Serialize + DeserializeOwned + Sized + 'static> Storable for T {}

Aggregate

As said, the aggregate is a central concept in DDD that is used in combination with event sourcing and CQRS. Officially the root entity should be called 'aggregate root', but because this a bit wordy Krill just calls it 'Aggregate' instead. There is trait defined for this that is implemented by CertAuth and RepositoryAccess (more on these later).

See src/commons/eventsourcing/agg.rs for the following code:

/// This trait defines an Aggregate for use with the event sourcing framework.
///
/// An aggregate is term coming from DDD (Domain Driven Design) and is used to
/// describe an abstraction where a cluster of structs (the aggregate) provides
/// a 'bounded context' for functionality that is exposed only by a single top-level
/// struct: the aggregate root. Here we name this aggregate root simply 'Aggregate'
/// for brevity.
///
/// The aggregate root is responsible for guarding its own consistency. In the
/// context of the event sourcing framework this means that it can be sent a command,
/// through the [`process_command`] method. A command represents an intent to
/// achieve something sent by the used of the aggregate. The Aggregate will then take
/// this intent and decide whether it can be executed. If successful a number of
/// 'events' are returned that contain state changes to the aggregate. These events
/// still need to be applied to become persisted.
pub trait Aggregate: Storable + Send + Sync + 'static {
    type Command: Command<StorableDetails = Self::StorableCommandDetails>;
    type StorableCommandDetails: WithStorableDetails;
    type Event: Event;
    type InitEvent: Event;
    type Error: std::error::Error + Send + Sync;

    /// Creates a new instance. Expects an event with data needed to
    /// initialize the instance. Typically this means that a specific
    /// 'create' event is passed, with all the needed data, or just an empty
    /// marker if no data is needed. Implementations must return an error in
    /// case the instance cannot be created.
    fn init(event: Self::InitEvent) -> Result<Self, Self::Error>;

    /// Returns the current version of the aggregate.
    fn version(&self) -> u64;

    /// Applies the event to this. This MUST not result in any errors, and
    /// this MUST be side-effect free. Applying the event just updates the
    /// internal data of the aggregate.
    ///
    /// Note the event is moved. This is done because we want to avoid
    /// doing additional allocations where we can.
    fn apply(&mut self, event: Self::Event);

    /// Applies all events. Assumes that:
    /// - the list is contiguous (nothing missing) and ordered from old to new
    /// - the events are all applicable to this aggregate
    /// - the version of the aggregate matches that of the first (oldest) event
    fn apply_all(&mut self, events: Vec<Self::Event>) {
        for event in events {
            self.apply(event);
        }
    }

    /// Processes a command. I.e. validate the command, and return a list of
    /// events that will result in the desired new state, but do not apply
    /// these events here.
    ///
    /// The command is moved, because we want to enable moving its data
    /// without reallocating.
    fn process_command(&self, command: Self::Command) -> Result<Vec<Self::Event>, Self::Error>;
}

As we will see further down, when we get to describe the AggregateStore<A: Aggregate> which ties of all this together, this allows us to focus on business logic in our aggregates.

Just make sure that you define your Command and Event type, and implement the process_command method. The result should be either an error, or Vec<Event>. In case the command is a no-op, return an empty Vec. Furthermore implement apply and let your aggregate state be updated with event data.

Commands and StorableDetails

As can be seen in the trait above, Krill expects that an Aggregate defines a type that it will use for commands. The command MUST implement the Command trait which is defined in src/commons/eventsourcing/cmd.rs:

//// Commands are used to send an intent to change an aggregate.
///
/// Think of this as the data container for your update API, plus some
/// meta-data to ensure that the command is sent to the right instance of an
/// Aggregate, and that concurrency issues are handled.
pub trait Command: fmt::Display + Send + Sync {
    /// Identify the type of storable component for this command. Commands
    /// may contain short-lived things (e.g. an Arc<Signer>) or even secrets
    /// which should not be persisted.
    type StorableDetails: WithStorableDetails;

    /// Identifies the aggregate, useful when storing and retrieving the event.
    fn handle(&self) -> &Handle;

    /// The version of the aggregate that this command updates. If this
    /// command should update whatever the latest version happens to be, then
    /// use None here.
    fn version(&self) -> Option<u64>;

    /// The actor who sent the command. There is no default so as to avoid
    /// accidentally attributing a command by a user instead as if it were an
    /// internal command by Krill itself.
    fn actor(&self) -> &str;

    /// Get the storable information for this command
    fn store(&self) -> Self::StorableDetails;
}

We define the WithStorableDetails trait in the same file. As mentioned in the code comments above, we use this when we save commands to the audit log, because the actual command may contain a secret - or more likely an ephemeral thing that cannot and should not be saved - such as a KrillSigner which an Aggregate can use as part of the executing the command.

/// Must be implemented for all 'StorableDetails' used in Commands.
///
/// In addition to implementing Storable so that the details can be stored
/// *and* retrieved, the details also need to be able to present a generic
/// CommandSummary for use in history.
pub trait WithStorableDetails: Storable + Send + Sync {
    fn summary(&self) -> CommandSummary;
}

Skipping some details now (it's in the code), this is ultimately used when we store a command using the following struct:

/// A description of a command that was processed, and the events / or error
/// that followed. Commands that turn out to be no-ops (no events, no errors)
/// should not be stored.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct StoredCommand<S: WithStorableDetails> {
    actor: String,
    time: Time,
    handle: Handle,
    version: u64,  // version of aggregate this was applied to (successful or not)
    sequence: u64, // command sequence (i.e. also incremented for failed commands)
    #[serde(deserialize_with = "S::deserialize")]
    details: S,
    effect: StoredEffect,
}

Finally StoredEffect here tells us whether the command was successful, or not:

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "result")]
pub enum StoredEffect {
    Error { msg: String },
    Success { events: Vec<u64> },
}

Events

As mentioned above an Aggregate must specify which type of event it uses. Events MUST implement the Event trait (src/commons/eventsourcing/evt.rs):

pub trait Event: fmt::Display + Eq + PartialEq + Send + Sync + Storable + 'static {
    /// Identifies the aggregate, useful when storing and retrieving the event.
    fn handle(&self) -> &Handle;

    /// The version of the aggregate that this event updates. An aggregate that
    /// is currently at version x, will get version x + 1, when the event for
    /// version x is applied.
    fn version(&self) -> u64;
}

Events are stored using the StoredEvent struct:

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct StoredEvent<E: fmt::Display + Eq + PartialEq + Storable + 'static> {
    id: Handle,
    version: u64,
    #[serde(deserialize_with = "E::deserialize")]
    details: E,
}

So, note that StoredEvent is not generic over Event. But, in practice the defined Event for our two main aggregates are of a type of StoredEvent:

pub type CaEvt = StoredEvent<CaEvtDet>;

IMPORTANT: We can change the internal implementation of an Aggregate pretty freely. As long as we keep backwards compatibility with regards to past events, we can just delete the snapshots and rebuild the current state based on past events. Well, if you look closely at the migration code in `src/upgrades/v0_9_0' you will find that it is of course possible to add code to parse all past commands and/or events, and reformat them. But, it's a major amount of work to do so, so it's better to avoid this if we can. Also note, that we can never add new information to past events. They already happened, so we have to deal with that.

Hybrid Model

So, time for a confession... and a digression that will make sense when we talk about event listeners, and things triggered by events.

Before Krill version 0.9 ALL CA and Publication Server functions were implemented in aggregates and used event sourcing. However, as you may know CAs in the RPKI have to re-publish CRLs and Manifests very, very often. Even when there are no changes to ROAs these objects must be refreshed before they go stale. By default Krill re-publishes every 16 hours.

This resulted in an enormous amount of commands and events to be generated for CAs, for a change that is done automatically - and which does not reflect any semantic change for a CA. Keeping all this stuff in the history led to excessive disk usage, and an audit trail that is hard to follow. It also has a serious impact on rebuilding state based on events from scratch i.e. without using snapshots.

Therefore we decided to implement a hybrid model in Krill. The Krill CertAuth is still in charge of almost all changes, and in particular all semantic changes that users made. But, the generation of Manifests and CRLs is offloaded to associated component CaObjects that just keeps the latest Manifest and CRL. It can re-sign these because it has access to a KrillSigner and it can get the public key identifier needed for signing from the CertAuth.

This is relevant here, because under the hood we use a PreSaveEventListener to ensure that a new Manifest and CRL are written when there is a change in ROAs or issued certificates, observed in events.

Event Listeners

The Krill eventsourcing stack defines two different EventListener traits which are called by the AggregateStore (see below) when an Aggregate is successfully updated. The first is called before updates are saved, and it can fail, the second is called after all changes have been applied, and cannot fail:

/// This trait defines a listener for events which is designed to receive
/// the events *before* the Aggregate is saved. Thus, they are allowed
/// to return an error in case of issues, which will then roll back the
/// intended change to an aggregate.
pub trait PreSaveEventListener<A: Aggregate>: Send + Sync + 'static {
    fn listen(&self, agg: &A, event: &[A::Event]) -> Result<(), A::Error>;
}
/// This trait defines a listener for events which is designed to receive
/// them *after* the updated Aggregate is saved. Because the updates already
/// happened EventListeners of this type are not allowed to fail.
pub trait PostSaveEventListener<A: Aggregate>: Send + Sync + 'static {
    fn listen(&self, agg: &A, event: &A::Event);
}

AggregateStore

When Krill interacts with an Aggregate, it does so through an AggregateStore<A: Aggregate>. The full code for this type can be found in src/commons/eventsourcing/store.rs. Here we will stick to the highlights of how this type works!

Essentially, the AggregateStore provides a convenient access layer to dealing with any Aggregate. Using this let's you focus on pure business logic in your aggregates. Essentially we just need to implement the Aggregate, Command and Event traits and then the framework will deal with storage concerns.

The most important pub function signatures (implementation omitted):

impl<A: Aggregate> AggregateStore<A>
where
    A::Error: From<AggregateStoreError>,

{
    /// Creates an AggregateStore using a disk based KeyValueStore 
    pub fn disk(work_dir: &PathBuf, name_space: &str) -> StoreResult<Self> { ... }

    /// Warms up the cache, to be used after startup. Will fail if any aggregates fail to load,
    /// or if any surplus commands or events not covered in their `StoredValueInfo` are found.
    /// The latter indicates an incomplete write 'transaction' happened when saving an updated
    /// version. Perhaps because a disk was full.
    ///
    /// In case this fails, the user may want to use the recover option to see what can be salvaged.
    pub fn warm(&self) -> StoreResult<()> { ... }

    /// Recovers aggregates to the latest consistent saved in the keystore by verifying
    /// all commands, and the corresponding events. Use this in case the state on disk is
    /// found to be inconsistent. I.e. the `warm` function failed and Krill exited.
    ///
    /// Note Krill has an option to *always* use this recover function when it starts,
    /// but the default is that it just uses `warm` function instead. The reason for this
    /// is that `recover` can take longer, and that it could lead silent recovery without
    /// alerting to operators to underlying issues.
    pub fn recover(&self) -> StoreResult<()> { .. }

    /// Adds a listener that will receive all events before they are stored.
    pub fn add_pre_save_listener<L: PreSaveEventListener<A>>(&mut self, sync_listener: Arc<L>) { ... }

    /// Adds a listener that will receive a reference to all events after they are stored.
    pub fn add_post_save_listener<L: PostSaveEventListener<A>>(&mut self, listener: Arc<L>) { ... }

    /// Adds a new aggregate instance based on the init event.
    pub fn add(&self, init: A::InitEvent) -> StoreResult<Arc<A>> { ... }

    /// Send a command to the latest aggregate referenced by the handle in the command.
    ///
    /// This will:
    /// - Retrieve the latest aggregate for this command.
    /// - Call the A::process_command function
    /// on success:
    ///   - call pre-save listeners with events
    ///   - save command and events
    ///   - call post-save listeners with events
    ///   - return aggregate
    /// on no-op (empty event list):
    ///   - do not save anything, return aggregate
    /// on error:
    ///   - save command and error, return error
    pub fn command(&self, cmd: A::Command) -> Result<Arc<A>, A::Error> { ... }

    /// Returns true if an instance exists for the id
    pub fn has(&self, id: &Handle) -> Result<bool, AggregateStoreError> { ... }

    // Lists all known ids.
    pub fn list(&self) -> Result<Vec<Handle>, AggregateStoreError> { ... }
}

As said, users of AggregateStore do not need to worry about the actual storage, as this is done inside of it using private functions. Still, it's good to talk a bit more about how this works. And looking at how the struct if built up will help to explain its inner workings:

pub struct AggregateStore<A: Aggregate> {
    kv: KeyValueStore,
    cache: RwLock<HashMap<Handle, Arc<A>>>,
    pre_save_listeners: Vec<Arc<dyn PreSaveEventListener<A>>>,
    post_save_listeners: Vec<Arc<dyn PostSaveEventListener<A>>>,
    outer_lock: RwLock<()>,
}
  • Locking transactions

First of all, note the presence of outer_lock. This is used a transactional lock, across ALL aggregates. The AggregateStore will hold a write lock during any updates, i.e. when an Aggregate is added, or when a command is sent to it. And it will use a read lock for other operations.

This is not the most efficient way of doing things, and it should be revised in future - especially when non-disk-based KeyValueStore options come into play.

Locking across all Aggregate instances on updates could be optimized already. We could have separate locks for each instead, but we would need to manage these locks when new instances are added or removed, and it does not really matter in real terms of performance today. So, it is just a simple implementation for now.

Another thing worth mentioning here is that we keep a key value pair for each Aggregate that describes its current version information. The structure is as follows:

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct StoredValueInfo {
    pub snapshot_version: u64,
    pub last_event: u64,
    pub last_command: u64,
    pub last_update: Time,
}

If a command has no effect, i.e. there is no error and no change, then it is simply forgotten. But, if a command resulted in changes, or an error, then it is saved. In case of an error we simply save the command and a description of the error. In case of success we save the command and all events. Whenever a command is saved the last_command field is updated. If the command was successful then the last_event and last_update values are also updated.

  • Storing / Retrieving

The AggregateStore uses a KeyValueStore to save/retrieve key value pairs. Commands and events are saved and retrieved this way. The AggregateStore uses a strategy for key naming - which is probably too detailed for this documentation. Values are saved/retrieved using JSON (serde_json). In addition to commands and events we also save a current, and backup snapshot for each Aggregate, and the self.cache is updated.

When an AggregateStore needs to get an Aggregate it will first retrieve the latest StoredValueInfo from the KeyValueStore. Then it will try to get the Aggregate from its self.cache.

If the AggregateStore cannot find an entry in the cache then it will try to rebuilt the Aggregate by retrieving and deserializing the current snapshot, and if that fails the backup snapshot, and if that also fails by instantiating it using the initialization event (delta-0.xml).

In either case (cache or no cache) it will now verify whether the version of the Aggregate matches the latest_event found in the StoredValueInfo. If the number is lower, then it will retrieve all missing events from the KeyValueStore and apply them.