XTDB: de tijdreizende database (Crux)

Jordi van Os

Jordi van Os

Software Engineer @ Avisi Labs

Published: 13 October, 2021

De noodzaak van bitemporele databases

Als bedrijf willen wij weten waar we stonden, staan en naartoe gaan. Bedrijfsdata die hier zicht op geeft komt in vele vormen voor. Bij Avisi is zulke data verspreid over talloze Confluence-pagina's, Google Drive-documenten en rapporten van verschillende externe tools en services. Door de fragmentering van de data is het lastig om de informatie overzichtelijk te maken en aan elkaar te relateren. Hiervoor bouwen wij bij Avisi Labs een platform voor bedrijfsdatavisualisatie, genaamd Innovision.

Een bedrijf is niet statisch. Bedrijven veranderen continu; personeel komt erbij, gaat weg of wisselt van functie; teams worden opgericht en ontbonden; projecten komen en gaan; enzovoorts. Om de opslag van dit temporele aspect van de data te faciliteren moet een passende database gekozen worden.

Bij Innovision hebben wij gekozen voor de database XTDB. XTDB is een bitemporele database, die ontwikkeld is voor use-cases waarin het tijdsgebonden aspect van data van bijzonder belang is.

In deze blog delen wij de informatie die ons te pas kwam om een productiewaardige backend te bouwen met XTDB.

Figuur 1. Innovision
Figuur 1: Innovision relateert data aan elkaar via een graphview

Xtdb als bitemporele database
Bitemporele databases

De meest voorkomende soort database is een current database. De data die in een current database te vinden is correleert - volgens conventie - met de huidige (current) situatie in de werkelijkheid. Zonder expliciete audit-logging zal verwijderde data ontoegankelijk worden. Data die pas actueel wordt in de toekomst zal ook nog niet te vinden zijn in een current database. Een current database representeert dus een "snapshot" van het huidige moment, zonder kennis te kunnen dragen van wat daarvoor of daarna gebeurt.

Een temporele database slaat voor iedere datamutatie een tijdsgebonden metriek op. Een veelvoorkomende metriek die temporele databases bijhoudt heet de valid timeDeze valid time representeert het moment waarop de datamutatie actueel is. Data wordt zelden expliciet verwijderd uit een temporele database; daarentegen worden momenten gedefinieerd waarop de data een mutatie ondergaat. Zo zal een medewerker Jan Jansen in 2012 zijn komen werken bij een bedrijf. In de database kan een PUT-operatie staan die Jans data toevoegt, met een valid time in 2012. Wanneer Jan bekendmaakt dat hij volgend jaar dit bedrijf gaat verlaten, kan een DELETE-operatie toegevoegd worden voor Jan met een valid time in het komende jaar. Een valid time is het moment waarop de data geldig is dus losgekoppeld van het moment dat je de database-transactie uitvoert.

Naast de valid time is een belangrijke metriek de transaction timeDeze transaction time staat voor het moment waarop een datamutatie toegevoegd werd aan de database. Het kunnen opvragen van een transaction time blijkt in verschillende situaties handig te zijn. Zo demonstreren wij later in deze blog hoe de transaction time gebruikt wordt om te voldoen aan de AVG-wetgeving.

Wanneer een database zowel de valid time als transaction time bijhoudt, spreken we van een bitemporele database. Sommige databases zullen enkel een transaction time kennen. Deze worden gerekend tot temporele databases.

Tritemporele databases bestaan ook. Hierin wordt decision time gebruikt om vast te kunnen leggen wanneer besloten werd om een datamutatie toe te voegen. Voor onze use-case is deze extra complexiteit echter overbodig.

Figuur 2,3,4 databases

Figuur 2: Een current database houdt enkel één snede vast van alle mogelijke momenten in de tijd. Een temporele database kan data in het verleden of zelfs de toekomst toevoegen. Bij de 
bitemporele database correleert de transaction time met het toevoegen van een datamutatie, en hoeft deze niet overeen te komen met de valid time.

Gebruik van XTDB

XTDB is open source (MIT-licentie) en is gratis voor commercieel gebruik. In het gebruik werkt XTDB als een document database (NoSQL).

XTDB is ontwikkeld met en voor Clojure. De huidige XTDB-API kent nog veel gelijkenissen met de Clojure-programmeertaal. Clojure draait - net als Java en Kotlin - in de JVM. XTDB implementeert naast een Clojure-API hierdoor ook een Java-API. Omdat Java interoperabel is met Kotlin, kan XTDB in zowel Java- als Kotlin-applicaties gebruikt worden. In deze blog is Kotlin gebruikt voor de codevoorbeelden aangezien de backend van Innovision met Kotlin ontwikkeld is.

Transacties

Om het temporele aspect te demonstreren schetst figuur 3 de situatie van de casus uit paragraaf 1.1. In deze fictieve situatie komt "Jan" in 2012 werken in "Team Blauw" van een bedrijf. In 2015 is hij overgeplaatst naar het nieuw opgerichte "Team Rood". Jan geeft aan dat hij volgend jaar - in 2022 - het bedrijf gaat verlaten.Figuur 5 tijdlijn Jan
Figuur 3: Tijdlijn van Jans loopbaan in een denkbeeldig bedrijf

Start eerst een XTDB-node. Door geen configuratie te specificeren wordt er een in-memory instantie gestart. Deze instantie kan data niet onthouden tussen executies van de code:

// Start een in-memory XTDB instance
val xtdb = IXtdb.startNode()


Data voor transacties beschrijven we met de factory functions van XtdbDocument. Hierin specificeren we via key-value pairs de naam, achternaam, adres en het team waarin Jan werkt. De tweede transactie representeert het moment waarop Jan van team wisselde. XTDB kan geen specifieke velden bijwerken in een transactie. Daarom maken wij een kopie van zijn originele velden en vervangen we enkel het team-veld met de naam van het nieuwe team.

Let erop dat wij handmatig het id specificeren van het document. Id's kunnen UUID's, strings, nummers en zelfs maps zijn. In dit voorbeeld gebruiken we de string "jan".

// Maak transacties aan
val documentInitieel = XtdbDocument.builder("jan")
    .put("voornaam", "Jan")
    .put("achternaam", "Jansen")
    .put("adres", "Jansestraat 1")
    .put("team", "Team Blauw")
    .build()

val documentTeamwisseling = documentInitieel.plus("team", "Team Rood")

Een Transaction bestaat uit één of meerdere operaties. Bij een put-operatie geven we het document mee dat in de database geplaatst wordt. Met de GregorgianCalendar-klasse kunnen we een Date specificeren - de valid time - waarop deze operatie valide is. Wanneer geen datum gespecificeerd is, staat de valid time gelijk aan de transaction time, of met andere woorden het daadwerkelijke moment waarop de transaction gesubmit is.

De submit van de transactie is asynchroon. Met awaitTx kunnen we wachten tot de submit verwerkt is. Optioneel kan een timeout meegeven worden, die in het voorbeeld null is.

// Voer transacties uit in de database
// Hier specificeren we de valid time van de transactie
val transaction = xtdb.submitTx(buildTx { tx: Transaction.Builder ->
    tx.put(documentInitieel, GregorianCalendar(2012, Calendar.APRIL, 4).time)
    tx.put(documentTeamwisseling, GregorianCalendar(2015, Calendar.OCTOBER, 25).time)
    tx.delete("jan", GregorianCalendar(2022, Calendar.JANUARY, 1).time)
})

// Wacht tot de transactions compleet zijn
xtdb.awaitTx(transaction, null)


Data ophalen & Datalog query's

XTDB's querytaal is een dialect van Datalog. Een query beschrijft de relaties tussen documenten, velden en waarden. XTDB haalt vervolgens alle data op die matcht met de beschreven datastructuur. XTDB werkt daardoor als een graph database waarin data beschreven wordt door nodes en verbindingen tussen nodes.

Figuur 6 toont hoe de volledige database graph eruitziet tussen 2012 tot 2015, in de situatie zoals beschreven in paragraaf Transacties.Figuur 6 Graph tussen 2012 en 2015-1Figuur 6: Volledige database graph tussen 2012 tot 2015

Met de openDB-functie openen we een snapshot van de database uit deze periode:

val snapshot = xtdb.openDB(GregorianCalendar(2014, Calendar.JULY, 1).time)

De
eenvoudigste manier om documenten op te halen is door hen direct op te vragen met het id. Merk op dat Jan in deze periode bij "Team Blauw" zit:
// Haal XtdbDocument op a.d.h.v. id
val entityResult = snapshot.entity("jan")
println(entityResult.toMap().map { it.toString() })
// [[:voornaam "Jan"], [:adres "Jansestraat 1"], [:team "Team Blauw"], [:achternaam "Jansen"], [:xtdb.db/id "jan"]]
Met datalog kan je query's schrijven. In dit voorbeeld halen we een lijst op van alle adressen van iedereen document dat een "adres"-veld bevat. In de :where-clause beschrijven we de relatie tussen objecten en velden. Alle data die voldoet aan de relationele beschrijving is beschikbaar om in de :find-clause af te vangen. In dit geval returnen we de veldwaarde:
// Query alle adressen met datalog
val queryResult = snapshot.query("""{:find [?a] :where [[_ :adres ?a]]}""")
println(queryResult)
// #{["Jansestraat 1"]}

In vele gevallen willen we niet individuele velden terugkrijgen, maar complete documenten aan de hand van een relationele beschrijving. Dit kan met een pull gedaan worden. In het voorbeeld halen we alle documenten op die een team-veld bevatten met de waarde "Team Blauw":

// Query documenten met veld "team" gelijk aan "Team Blauw"
val pullResult = snapshot.query("""{:find [(pull ?doc [*])] :where [[?doc :team "Team Blauw"]]}""")
println(pullResult)
// #{[{:achternaam "Jansen", :voornaam "Jan", :adres "Jansestraat 1", :team "Team Blauw", :xtdb.db/id "jan"}]}

Wanneer je klaar bent met een database snapshot, vergeet hem dan niet te sluiten door close() aan te roepen of gebruik Closable::use om AutoCloseable te benutten.

Geschiedenis ophalen

Met de entityHistory-functie kan de totaalgeschiedenis van een document opgevraagd worden. Hiermee krijg je een overzicht van alle transacties die het document hebben gemuteerd. In het codevoorbeeld halen we de documentgeschiedenis van "jan" op tot het jaar 2050. Dit omvangt alle drie transacties die in de database staan. Met HistoryOptions kan de invulling van de data verder verfijnd worden, zoals de geschiedenis beperken tot een specifieke valid time of transaction time.

In de teruggegeven history staat voor iedere transactie de valid time, transaction time, transaction id, hash van het document en optioneel het document zelf. Met deze informatie is Jans tijdlijn in de database inzichtelijk geworden.

val options = HistoryOptions
    .create(HistoryOptions.SortOrder.ASC)
    .withDocs(true)

val history = xtdb.db(GregorianCalendar(2050, Calendar.JANUARY, 1).time)
    .entityHistory("jan", options)

history.forEach { println(it) }
// {:xtdb.api/tx-time #inst "2021-09-15T08:46:20.544-00:00", :xtdb.api/tx-id 0, :xtdb.api/valid-time #inst "2012-04-03T22:00:00.000-00:00", :xtdb.api/content-hash #xtdb/id "15a71f052a555d58c27ad71cc9824347d01b443e", :xtdb.api/doc {:achternaam "Jansen", :voornaam "Jan", :adres "Jansestraat 1", :team "Team Blauw", :xt/id "jan"}}
// {:xtdb.api/tx-time #inst "2021-09-15T08:46:20.544-00:00", :xtdb.api/tx-id 0, :xtdb.api/valid-time #inst "2015-10-24T22:00:00.000-00:00", :xtdb.api/content-hash #xtdb/id "e880f80741764a8113bc3d61d53244186af146e3", :xtdb.api/doc {:voornaam "Jan", :adres "Jansestraat 1", :team "Team Rood", :achternaam "Jansen", :xt/id "jan"}}
// {:xtdb.api/tx-time #inst "2021-09-15T08:46:20.544-00:00", :xtdb.api/tx-id 0, :xtdb.api/valid-time #inst "2021-12-31T23:00:00.000-00:00", :xtdb.api/content-hash #xtdb/id "0000000000000000000000000000000000000000", :xtdb.api/doc nil}

Architectuur van XTDB

Achter de schermen bestaat XTDB uit drie verschillende componenten: de transaction log die mutaties van document bijhoudt; de document store die de documenten zelf opslaat; en de index store die de index in de database bevat.

Aan de hand van je gebruikswensen kan de ontwikkelaar instellen welke database-implementaties gebruikt worden. Voor een database die relatief veel wegschrijft en weinig uitleest kan overwogen worden om RocksDB te gebruiken. In de use-case van Innovision is gekozen voor LMDB, vanwege de snellere uitvoertijd van query's.

Hieronder is een voorbeeld van een configuratie waarin LMDB gebruikt is voor zowel de transaction log, document store en index store.

{
  "xtdb/index-store": {
    "kv-store": {
      "xtdb/module": "xtdb.lmdb/->kv-store",
      "db-dir": "data/lmdb/ind"
    }
  },
  "xtdb/document-store": {
    "kv-store": {
      "xtdb/module": "xtdb.lmdb/->kv-store",
      "db-dir": "data/lmdb/docs"
    }
  },
  "xtdb/tx-log": {
    "kv-store": {
      "xtdb/module": "xtdb.lmdb/->kv-store",
      "db-dir": "data/lmdb/txs"
    }
  }
}
Een overzicht van de mogelijke database-implementaties is te vinden op de configuratie-guide van XTDB met voorbeelden hoe deze configuratie in te laden is. Verschillende database-implementaties kunnen gebruikt worden met verschillende componenten.

 

Aandachtspunten
Veranderende API

Op het moment van schrijven is de Java-API van XTDB nog niet definitief, en nog onderhevig aan veranderingen en verbeteringen. Met behulp van een abstractielaag tussen XTDB en de rest van de backend kunnen veranderingen aan de XTDB-API beter opgevangen worden.

Hard-delete van data

Omdat data na verwijdering toegankelijk blijft moet extra aandacht besteed worden aan het omgaan met data die compleet verwijderd dient te worden. Dit is van belang voor, bijvoorbeeld, de AVG-wetgeving. In de use-case van Innovision is het belangrijk dat persoonsgegevens van medewerkers die Avisi verlaten niet in de database blijven staan. Hiervoor is gebruikgemaakt van de evict-functie.

Evict verwijdert alle geschiedenis van een document, en is daarmee de enige functie waarmee geschiedenis ongedaan gemaakt kan worden. Dit is hoe wij AVG-compliant zijn door evict te gebruiken in combinatie met de documentgeschiedenis:

Eerst halen we de complete geschiedenis op van het document dat AVG-compliant gemaakt moet worden. In dit geval "jan". Daarna voeren we een hard delete uit met evict.

// Haal alle transacties op met documenten
val options = HistoryOptions
    .create(HistoryOptions.SortOrder.ASC)
    .withDocs(true)

val history = xtdb.db(GregorianCalendar(3000, Calendar.JANUARY, 1).time)
    .entityHistory("jan", options)

// Evict (hard delete) het complete document
val evictTx = xtdb.submitTx(buildTx { tx: Transaction.Builder ->
    tx.evict("jan")
})
xtdb.awaitTx(evictTx, null)
Met de documentgeschiedenis maken wij een nieuwe transactie aan, die een geanonimiseerde versie van het document opnieuw in de database plaatst met de originele valid time. Delete-operaties worden ook toegevoegd aan deze transactie.

Aangezien de Java-API van XTDB nog in ontwikkeling is, moeten Clojure-variabelen handmatig opgevraagd worden met de utility-functie Keyword::intern.
// Anonimiseer data
val anonimisedTx = xtdb.submitTx(buildTx { tx: Transaction.Builder ->
    history.forEach { row ->

        // Haal info over de transactie op
        val validTime = row[Keyword.intern("xtdb.api/valid-time")] as Date
        val document = row[Keyword.intern("xtdb.api/doc")] as PersistentArrayMap?

        if (document != null) {
            // Anonimiseer deze versie van het document
            val team = document[Keyword.intern("team")] as String
            val anonimisedDocument = XtdbDocument.builder("jan").put("team", team).build()
            tx.put(anonimisedDocument, validTime)

        } else {
            // Verwijder het document als het bij deze transactie niet bestaat
            tx.delete("jan", validTime)
        }
    }
})
xtdb.awaitTx(anonimisedTx, null)
De data staat nu geanonimiseerd terug in de database. Wanneer we de geschiedenis van "jan" nogmaals ophalen zien we dat zijn adres en naam ontbreekt, maar het team waarin hij zat nog zichtbaar is. De valid times komen overeen met de niet-geanonimiseerde transacties, inclusief de delete-operatie. De transactie time is wel anders, dat nu het moment van het anonimiseren is.
{:xtdb.api/tx-time #inst "2021-09-15T09:45:56.794-00:00", :xtdb.api/tx-id 2, :xtdb.api/valid-time #inst "2012-04-03T22:00:00.000-00:00", :xtdb.api/content-hash #xtdb/id "4101a7881f35625537d7ef6db0718762637a5de0", :xtdb.api/doc {:team "Team Blauw", :xt/id "jan"}}
{:xtdb.api/tx-time #inst "2021-09-15T09:45:56.794-00:00", :xtdb.api/tx-id 2, :xtdb.api/valid-time #inst "2015-10-24T22:00:00.000-00:00", :xtdb.api/content-hash #xtdb/id "70e0aec00266c3006b27d388db59be7b372b2989", :xtdb.api/doc {:team "Team Rood", :xt/id "jan"}}
{:xtdb.api/tx-time #inst "2021-09-15T09:45:56.794-00:00", :xtdb.api/tx-id 2, :xtdb.api/valid-time #inst "2021-12-31T23:00:00.000-00:00", :xtdb.api/content-hash #xtdb/id "0000000000000000000000000000000000000000", :xtdb.api/doc nil}

De volledige persoonsinformatie bevat - naast het data op ieder moment - de valid time en transaction time van de documentmutatie. Door het bitemporele aspect van XTDB kan een geanonimiseerde vorm van deze persoonsinformatie met de juiste valid time en transaction time opnieuw in de database geplaatst worden nadat de data geëvict is.

Geavanceerdere functionaliteiten

XTDB is nog ontwikkeling. Hierdoor zullen sommige features pas in de toekomst toegankelijk worden. Verschillende geavanceerdere database-functionaliteiten - zoals query streaming, unique constraints en transaction functions - zijn reeds beschikbaar. De stabiliteit en huidige features van XTDB zijn voldoende voor een productiewaardige database. Een voor Avisi Labs gewilde feature is ondersteuning voor document-mapping, waarbij XtdbDocuments via - bijvoorbeeld Jackson - gemapt kunnen worden op dataklassen in Kotlin. Om deze functionaliteit toch te krijgen hebben wij een abstractielaag gebouwd die zo'n mapping-functie bevat.

Conclusie

Het bitemporele aspect van XTDB is perfect voor onze use-case waarin ieder detail van voorafgaande situaties behouden en inzichtelijk moet blijven. Met de datalog graph query's kunnen complexe relaties tussen data eenvoudig opgevraagd worden. XTDB is nog actief in ontwikkeling. Nieuwe, geavanceerdere, features worden nog uitgebracht. Toch biedt XTDB reeds genoeg om te dienen als een productiewaardige database voor onze backend. De Java-API is nog niet tot een stabiele versie ontwikkeld, waardoor updates naar nieuwe versies van XTDB op het moment van schrijven relatief ingrijpend kunnen zijn.

Het Apps-team binnen Avisi gebruikt XTDB al sinds 2019. Momenteel geniet XTDB nog niet van grote naamsbekendheid. Een grotere community helpt het XTDB-team om meer stabiliteit te brengen naar de API en een uitgebreidere featureset te bouwen. Bij Avisi Labs zijn wij tevreden met ons besluit om voor XTDB te kiezen en kijken wij uit naar de groei van de XTDB-community in de toekomst.

 

Related blogs

Did you enjoy reading?

Share this blog with your audience!