mirror of
https://github.com/NLnetLabs/krill.git
synced 2026-09-29 04:44:56 +02:00
458 lines
21 KiB
Markdown
458 lines
21 KiB
Markdown
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`](../../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`.
|
|
|
|
This is implemented in the `kvx` library:
|
|
https://github.com/nlnetlabs/kvx
|
|
|
|
There is a PR to port the `kvx` implementation back into the core Krill code (Krill is
|
|
the only user after all), and in the process make it support async. To support this,
|
|
the code was updated to rely on an enum rather than a trait for `KeyValueStore`. This
|
|
PR can be found here:
|
|
https://github.com/NLnetLabs/krill/pull/1152
|
|
|
|
Currently, only disk and memory (for testing) implementations are supported. Database
|
|
options may be added in future, but note that that will require async support. The `kvx`
|
|
library claims to support postgresql but it can't be used in Krill because while the
|
|
library is sync, it uses a runtime under the hood for the database connection and this
|
|
conflicts with Krill because it already uses hyper and tokio.
|
|
|
|
Opt-in locking on disk relies on `fd-lock` in `kvx` and plain `rustix` in the PR (only
|
|
supports UNIX). This locking is used by Krill to ensure that updates to CAs, the Publication
|
|
Server Access (which Publishers have access) and Publication Server Content are always
|
|
applied sequentially.
|
|
|
|
In principle, since the locking leverages `flock` which is supposedly NFS safe, this
|
|
should mean that as of 0.14.4 it is safe to run multiple active Krill instances using
|
|
the same shared NFS data directory. But.. this needs proper testing!
|
|
|
|
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:
|
|
```rust
|
|
/// 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`:
|
|
|
|
```rust
|
|
//// 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.
|
|
|
|
```rust
|
|
/// 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:
|
|
|
|
```rust
|
|
/// 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:
|
|
|
|
```rust
|
|
#[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`):
|
|
|
|
```rust
|
|
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:
|
|
```rust
|
|
#[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`:
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
/// 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>;
|
|
}
|
|
```
|
|
|
|
```rust
|
|
/// 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);
|
|
}
|
|
```
|
|
|
|
In a nutshell, we use the event listeners for two things:
|
|
- Trigger that the `CaObjects` for a CA gets an updated Manifest and CRL (pre-save).
|
|
- Trigger that follow-up tasks are put on the `Scheduler`, based on events.
|
|
|
|
As discussed in issue: https://github.com/NLnetLabs/krill/issues/1182
|
|
it would be best to remove the `PreSaveEventListener` trait and do everything
|
|
through (idempotent) triggered tasks on the queue in the `Scheduler`. Note
|
|
that Krill will add any missing tasks on this queue at startup, so this means
|
|
that even if an aggregate, like a CA, is saved and then the follow-up task
|
|
scheduling fails because of an outage, then the task will simply be re-added
|
|
when Krill restarts.
|
|
|
|
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):
|
|
|
|
```rust
|
|
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.
|
|
pub fn warm(&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:
|
|
/// - Wait for a lock for 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:
|
|
|
|
```rust
|
|
pub struct AggregateStore<A: Aggregate> {
|
|
kv: KeyValueStore,
|
|
cache: RwLock<HashMap<Handle, Arc<A>>>,
|
|
history_cache: Option<Mutex<HashMap<MyHandle, Vec<CommandHistoryRecord>>>>,
|
|
pre_save_listeners: Vec<Arc<dyn PreSaveEventListener<A>>>,
|
|
post_save_listeners: Vec<Arc<dyn PostSaveEventListener<A>>>,
|
|
}
|
|
```
|
|
|
|
- Applying changes
|
|
|
|
ALL changes to Aggregates, like `CertAuth` are done through the `command` function.
|
|
This function waits for a lock (using flock mentioned earlier) to ensure that
|
|
all changes to the Aggregate are applied sequentially.
|
|
|
|
First the `Aggregate` is retrieved from the in memory cache if present. If there
|
|
is no cached instance then latest snapshot is retrieved from storage instead. If
|
|
there is also no snapshot, then the INIT command (i.e. with version 0) is retrieved
|
|
and applied instead. Then the key value store is queried for follow-up `StoredCommand`
|
|
values (for the version of the `Aggregate`) which are then applied. Note that
|
|
this would mean, in a possible cluster set up that guarantees locking, that even
|
|
if a cluster node is behind the other, it will simply find the missing updates.
|
|
|
|
Once the latest `Aggregate` has been retrieved, the `Command` is sent to it. Note
|
|
that this `Command` is not a `StoredCommand`. `Command` contains the intent for a change,
|
|
while `StoredCommand` contains the result of such a change. The `Aggregate` is responsible
|
|
for verifying the command and it can return with: an error, no effect, or a vec
|
|
of change events.
|
|
|
|
If a command has no effect, i.e. there is no error and no change, then it is
|
|
simply forgotten.
|
|
|
|
If a command has an effect then a `StoredCommand` is created that contains a
|
|
storable representation of the command (e.g. certain values in a command, like
|
|
an `Arc<Signer>`, or `Arc<Config>` are not included) and either the error or
|
|
a Vec of Events. This `StoredCommand` gets a unique key name based on the
|
|
version of the `Aggregate` that it affects.
|
|
|
|
If there should be an existing key-value pair for this `StoredCommand`, then
|
|
this indicates that the locking mechanism failed somehow. This should not happen,
|
|
but if it did, then the command is NOT saved. Instead Krill exits with an error
|
|
message.
|
|
|
|
But if all is well (as expected), then the command with events is applied
|
|
to the aggregate. Note that this just updates its version in case of a command
|
|
that resulted in an error. The in-memory cached aggregate is updated to
|
|
help performance when retrieving it later. Then the command is saved.
|
|
|
|
- Retrieving
|
|
|
|
When retrieving an `Aggregate` for read access Krill actually follows
|
|
the same code path that is used for applying a `Command`, except that in
|
|
this case the underlying function that takes care of locking and retrieving
|
|
the latest Aggregate is called with `None` instead of `Some(command)`, so
|
|
it simply returns without trying to apply any changes. This ensures however,
|
|
that the same locking rules are observed in both cases and allows us to
|
|
avoid code duplication (thus increasing the loci for bugs).
|
|
|
|
- Snapshots
|
|
|
|
Note that we do not update the full snapshot on every change because this
|
|
could create a performance issue in cases where aggregates are big (e.g. a
|
|
CA with many children or objects) and serialization takes a long time. Instead,
|
|
updating the snapshots is done daily through a different code path from the `Scheduler`.
|
|
See `Task::UpdateSnapshots`. This code, again, actually uses the same underlying
|
|
function as above to retrieve the snapshot, this time setting the `save_snapshot`
|
|
parameter to true to ensure that the snapshot is saved.
|