Warning
Very possibly the information in this file is outdated!
- Journal should be operational even when replication is offline, ideally up to topic retention period. Let's say 24h
- The Journal will operate on huge streams of events, it is impossible to fit all in memory
Stream data from two storage when one is not consistent and the second lost his tail.
You might naively think that kafka-journal stores events in Kafka. That is not really true. It stores actions.
Actions:
- Append: Kafka record that contains list of events saved atomically
- Mark: With help of
Markaction we can prevent from consuming Kafka infinitely and stop upon marker found in topic - Delete: Indicates attempt to delete all existing events up to passed
seqNr. It will not delete future events - Purge: Indicates attempt to delete the whole journal fully - leave no data behind. The next Append will create a new journal
Action.Markis pushed to the topic in parallel with querying Cassandra for processed topic offsets- Concurrently:
- initiated query to Cassandra with streaming capabilities
- initiated Kafka consumer from last known offsets just to make sure there are no deletions
- Start streaming data from Cassandra filtering out deleted records
- When finished reading from Cassandra, kafka-journal might need to start consuming data from Kafka, in case replication is slow or does not happen at the moment
- If
Action.Mark's offset <= topic's offset from eventual storage, we don't need to consume Kafka at all (impossible best case scenario) - Don't wait for
Action.Markbeing fully published before start of Cassandra querying - In case reading Kafka while looking up for
Action.Delete, we can buffer some part of head to not initiate second read (most common scenario, replicating app is on track) - We don't need to initiate second consumer in case we managed to replicate all events within reading duration
- Use the pool of consumers and cache either head or deletions
- Subscribe for a particular partition rather than the whole topic, however, this limits caching capabilities
- Don't deserialize unrelated Kafka records
- We cannot stream data to client before making sure there are no
Action.Deletein not yet replicated Kafka head
- Measure three important operations of Kafka consumer: init, seek, poll. So we can optimise journal even better
- More corner cases to come in order to support re-partition [^_^]
- Decide on when to clean/cut head caches
Currently, there is only one Cassandra specific implementation for replication storage. The storage is multi-tenant, while each instance of Replicator can serve only one Cassandra keyspace.
Currently following Cassandra tables are used in replicator's keyspace:
metajournalcontains information about the "head" of "journals" (perkeyontopic)journalcontains all appended events per "journal" (events forkeyontopic)pointer2- exposes per topic-partition offsets of last fully replicated message's offset on Kafka- (technical)
settingused to track scheme upgrades - (technical)
locksused to guarantee single DDL application during scheme upgrades
There are several things that describe "journals":
- Key (
id+topic) mostly used as entity ID - SeqNr (1-based numbering!) used by
akka-persistenceorpekko-persistenceto track changes in actor's state since its creations (starts at1) - DeleteTo used to track useful part of entity's journal:
- part of entity's old journal can be removed till
seqNrwhere same entity "restarts" its life, like reaches "zero-state" - if snapshot of entity is made at
seqNr: 13, then user can savedeleteTo: 13to remove part of events, which are not important any more
- part of entity's old journal can be removed till
- PayloadMetadata contains business value
- ExpireAfter provides an indication after how long entity can be fully removed (actual deletion happens in PurgeExpired)
- SegmentNr
(zero-based numbering!) and SegmentSize
provides means to "split" journals in different Cassandra SSTables to more evenly distribute logical data on physical
storage (don't ask why the same name is used for 2 tables with different derivation algorithms):
- in
metajournaltablesegmentis calculated from entity'sid:See: SegmentNr.metaJournalval hashCode = key.id.toLowerCase.hashCode def apply(hashCode: Int, segments: Segments): SegmentNr = { val segmentNr = math.abs(hashCode.toLong % segments.value) new SegmentNr(segmentNr) {} }
- in
journaltable, it is calculated from entity'sseqNrandsegmentSize:See: SegmentNr.journaldef apply(seqNr: SeqNr, segmentSize: SegmentSize): SegmentNr = { val segmentNr = (seqNr.value - SeqNr.min.value) / segmentSize.value new SegmentNr(segmentNr) {} }
- in
Main implementation of replicator happens in ReplicatedCassandra.
There is full implementation of data adaption-replication from Kafka stream-like data to Cassandra-specific storage.
The Replicator initializes Kafka consumers and Cassandra session to start ReplicatedJournal.
- can expose list of so far discovered
topics - provides per-topic abstraction ReplicatedTopicJournal
- provides API for per-topic-partition access ReplicatedPartitionJournal
- is implemented in ReplicatedCassandra
- provides API for
offsetsto expose and set how far topic-partition has been fully replicated - provides access ReplicatedKeyJournal
- is implemented in ReplicatedCassandra
ReplicatedKeyJournal
defines the most important API of the replicator - it handles all actions and applies them on Cassandra storage.
The APIs are per key (id + topic):
append- appends journal entries intojournaltable and updatesseqNr,offsetandupdatedfields inmetajournaltabledelete- deletes entries fromjournaltable till requestedseqNrand updatesdeleteTo,offsetandupdatedfields inmetajournaltable, on next recovery entity will:- if
deleteTois less thanseqNr, get recovered from left-over events - if
delerteTois equal toseqNr, recovered actor will start with zero-state atseqNr
- if
purge- delete all journal's entries injournaltable as well as removed entry inmetajournaltable - next reincarnation of journal will start from very beginning withseqNr: 1- is implemented in ReplicatedCassandra
Assuming that we have aggregate: A, with few journal events:E0, E1. It can:
- occupy both
metajournalandjournaltables - occupy only
metajournaltable - not exist in any table
Any aggregate has several important parts:
seqNr- practically its agedeleteTo- its meaningful "start" age, usuallyseqNrof aggregate's last "zero" state or snapshotseqNr - deleteTojournal records - ideally means that there are "exactly" so many events, but there can be "gaps" (for example inappendaction larger value forseqNris set)- [Cassandra implementation specific detail]
segmentSize- defines how entity is distributed on physical storage in Cassandra
Generally correct, but wrong assumptions:
- aggregate's
deleteTois eithernoneor has value that is no larger thanseqNr seqNris sequentially growing and doesn't "skip" values- all aggregates use same
segmentSize
aggregate in metajournal table |
journal table (seqNr, segmentNr) |
snapshot table (seqNr) |
notes |
|---|---|---|---|
A(seqNr: 3, deleteTo: none, segmentSize: 5) |
E(1,0), E(2, 0), E(3, 0) |
||
A(seqNr: 5, deleteTo: none, segmentSize: 3) |
E(1,0), E(2, 0), E(3, 0), E(4,1), E(5, 1) |
||
A(seqNr: 5, deleteTo: 2, segmentSize: 3) |
E(3, 0), E(4,1), E(5, 1) |
events are stored in 2 segments | |
A(seqNr: 5, deleteTo: none, segmentSize: 3) |
E(1,0), E(2, 0), E(3, 0), E(4,1), E(5, 1) |
S(5) |
all events are preserved and has snapshot |
A(seqNr: 5, deleteTo: 5, segmentSize: 3) |
all events are deleted, "zero state" | ||
A(seqNr: 5, deleteTo: 5, segmentSize: 3) |
S(5) |
all events are deleted, has snapshot | |
A(seqNr: 5, deleteTo: 5, segmentSize: 3) |
S(2), S(5) |
all events are deleted, has 2 snapshots |
Markaction is no-op for changes in storageAppendaction:- when there is no entry in DB:
- create record in
metajournaltable withseqNrof last event anddeleteTo: none - append events in
journaltable
- create record in
- when there is entry for aggregate:
- update
seqNrinmetajournaltable - append events in
journaltable
- update
- when there is no entry in DB:
Deleteaction:- when there is no entry in DB, it will:
- create record in
metajournaltable withseqNranddeleteToboth set toDelete.tovalue (allows to "reset" journal)
- create record in
- when there is entry for aggregate:
- update
deleteToinmetajournaltable - delete events between aggregate's
deleteToandseqNr <= Delete.to
- update
- when there is no entry in DB, it will:
Purgeaction:- when there is no entry in DB, it will do nothing (no-op)
- when there is entry for aggregate:
- delete all events for aggregate from
journaltable between aggregate'sdeleteToandseqNrincluding - delete entry in
metajournaltable
- delete all events for aggregate from
AppendandDeleteactions also updatepartitionandoffsetfields inmetajournaltable to allow easier lookup of action, which caused the last change, in Kafka