SQL, NoSQL, en waarom ik Kanta DB ben begonnen

SQL is nog steeds niet voor niets de standaard…

Als ik vandaag een conventionele Python-service zou bouwen, zou ik waarschijnlijk beginnen met PostgreSQL, SQLAlchemy en Alembic. PostgreSQL geeft me echte UUID’s, JSONB, arrays, transacties, constraints en uitstekende indexering. SQLAlchemy mapt het meeste daarvan netjes naar Python. Alembic houdt schema-wijzigingen expliciet en versiebeheerd.

Daarmee kom ik een heel eind voordat iets me begint te storen.

… maar het model heeft scherpe randjes

Een databaseschema en een Python-datastructuur zijn niet helemaal hetzelfde.

Met SQLAlchemy kan ik getypeerde modellen definiëren, uuid.UUID direct gebruiken tegen PostgreSQL UUID-kolommen, JSONB mappen naar Python-containers en de meeste routinematige conversies uit mijn eigen code houden. Dat is aanzienlijk beter dan elke databasewaarde als string behandelen of handmatig SQL in elkaar zetten.

Toch wordt het ORM-model een bijzonder soort object. Het draagt kolommen, relaties, sessiegedrag en persistentieregels met zich mee. Als de rest van het programma gewone applicatiestructuren wil, laat ik óf databasezaken naar buiten lekken, óf ik voeg weer een conversielaag toe.

Het probleem wordt zichtbaarder zodra het schema verandert.

Een veld toevoegen aan een Python-structuur voelt triviaal. Een kolom toevoegen aan gepersisteerde data betekent het model aanpassen en een migratie maken. Ingrijpender wijzigingen vereisen datatransformaties en compatibiliteitsbeslissingen. Alembic handelt dit verstandig af, maar ik moet alsnog een geschiedenis van structurele wijzigingen onderhouden, alleen zodat oude rijen nieuwe rijen kunnen worden.

Dat is geen gebrek van PostgreSQL. De database heeft zich aan een schema gecommitteerd, dus het wijzigen daarvan heeft gevolgen.

Geschiedenis creëert weer een extra laag. Als ik wil weten wie een waarde heeft gewijzigd, wanneer dat gebeurde, of hoe het record er afgelopen dinsdag uitzag, moet ik dat modelleren. Ik kan audittabellen, triggers, timestamp-kolommen of een event-sourcinglaag toevoegen. PostgreSQL kan dat allemaal best goed ondersteunen.

Maar de normale rij representeert nog steeds wat er nu bestaat. De geschiedenis blijft iets dat ik eromheen bouw.

Alternatieven voor SQL

Zin in een lekker kopje NoSQL

MongoDB neemt wat frictie weg

MongoDB brengt de vorm van de data veel dichter bij de vorm die ik in het programma gebruik.

Ik kan geneste documenten direct opslaan, velden toevoegen zonder een tabel aan te passen, en oudere en nieuwere records naast elkaar laten bestaan terwijl de applicatie beide begrijpt. Dat maakt schema-evolutie vaak minder ceremonieel. In plaats van eerst de hele database te migreren, kan ik oude documenten soms upgraden wanneer ik ze lees of aanpas.

Het schema bestaat nog steeds. Mijn code verwacht nog steeds bepaalde velden met bepaalde betekenissen. MongoDB geeft me alleen meer vrijheid over wanneer ik die afspraak afdwing.

Redis geeft me uitstekende bouwstenen

Redis is heerlijk praktisch.

Als ik een cache, queue, counter, sorted set, distributed lock of vluchtige gedeelde state nodig heb, heeft Redis meestal een compact antwoord.

Natuurlijk is het, als mijn data in JSON of een ander gestructureerd formaat komt, ook volledig aan mij om die af te breken naar die Redis-primitieven, of het er gewoon als geheel in te dumpen en al die fijne gereedschappen te negeren.

Pushen en pullen

Laat het me weten zodra iemand aan mijn data komt

Firebase begint bij synchronisatie

Firebase kiest een directere route. Zijn databases behandelen live client-updates als onderdeel van het product.

Ik kan een listener aan data koppelen en de client wijzigingen laten ontvangen zodra ze gebeuren. Offline-gedrag en herverbinding horen ook bij hetzelfde systeem, in plaats van later als een WebSocket-project op te duiken.

Dat is aantrekkelijk.

We hebben alle wijzigingen al

MongoDB heeft hiervoor het sterke ingebouwde antwoord. Change Streams laten me een collectie, database of hele deployment volgen en inserts, updates, deletes enzovoort ontvangen. Updates bevatten normaal gesproken de gewijzigde velden, en elk event draagt een resume token, zodat ik opnieuw kan verbinden en verder kan gaan waar ik gebleven was, zolang de oplog dat punt nog bevat.

PostgreSQL kan ook daadwerkelijke databasewijzigingen blootleggen via logical decoding en replicatie. Redis Change Streams bieden een vergelijkbare oplossing in dat domein.

Change-data-capturesystemen bouwen daar zeer capabele pipelines bovenop, maar dan moeten we zelf SQL-statements of Redis-commando’s parsen en de state bijhouden.

Publish en subscribe

PostgreSQL en Redis bieden ook oldskool berichtenkanalen.

Ik zou LISTEN/NOTIFY of PUB/SUB kunnen gebruiken en een notificatie sturen vanuit dezelfde transactie die de data wijzigt. Dat vermijdt wat van de lelijkheid van een ongerelateerde message bus.

Maar ik moet nog steeds notificatieberichten maken en ontvangen om te beslissen wat ik moet lezen en wat ik moet sturen. En er zitten race conditions tussen de daadwerkelijke wijziging en de notificatie.

Voor mijn probleem voelde het alsof ik behoorlijk ver onder de abstractie begon die ik eigenlijk wilde.

Ik wilde niet alleen weten dát de state was veranderd.
Ik wilde de wijziging zelf, en de state ervoor en erna.

Misschien moet ik zelf iets maken?

Na al het bovenstaande gebruikt te hebben, en altijd te merken dat het ORM-gekruip door mijn codebase ondraaglijk werd, ben ik eindelijk begonnen met werken aan een idee dat ik jaren en jaren stilletjes had ontwikkeld.

Om mijn eigen database te maken. Je weet wel, iets waarvan ze je altijd vertellen dat je het niet eens moet proberen.

Laat de log de database zijn

Dat werd het startpunt voor Kanta.

In plaats van de nieuwste state als primaire record op te slaan en daar geschiedenis omheen te bouwen, wilde ik wijzigingen opslaan.

Een object begint met een lege state. Elke latere operatie legt alleen vast wat er veranderde, samen met een timestamp en welke andere metadata ook bij die wijziging hoort.

De huidige state ontstaat door de log toe te passen. Nu heeft geschiedenis geen eigen schema meer nodig. Queries hoeven niet langer de meest recente timestamp te vinden, omdat ze op elk gewenst moment op het state-object werken.

Eén structuur van begin tot eind

Msgspec geeft me getypeerde, compacte datastructuren met zeer snelle serialisatie. Net als dataclasses of Pydantic handelt het geneste structuren en gangbare native types zoals UUID’s, enums en datetimes af zonder de objecten in ORM-entiteiten te veranderen. Je datastructuren definiëren wordt zo simpel:

class Data(msgspec.Struct):
    servername: str
    users: dict[UUID, User]   

Dat betekent dat ik hetzelfde soort object door het hele programma kan gebruiken. Ik kan het serialiseren. Ik kan het over het netwerk sturen. Ik kan de wijzigingen ervan persisteren. Ik kan het aan de andere kant reconstrueren.

Ik heb niet één class voor mijn applicatie nodig, nog een voor SQLAlchemy, nog een schema voor serialisatie, en kleine conversiefuncties die daartussen bemiddelen.

Het leeft

Ik heb een simpele JSONL-logger in elkaar geknutseld, één wijziging per regel, met een jsondiff-wijzigingsrecord erop. Het was geen binair formaat zoals ik misschien had gewild, maar het is iets dat doodsimpel te bewerken en te debuggen is.

Reads zijn simpelweg reads uit Python-variabelen, veel sneller dan Redis of welke externe database dan ook!

Writes vereisten extra nadenken. In plaats van de database te vertellen wat er moet veranderen, bewerken we de state en persist de database een wijzigingsrecord.

with kanta.transaction(action="new_user"):
    data.users[uuid7()] = User(...)

Merk op dat er geen async with of await in zit, hoewel we met async Python werken. De transactie is direct en synchroon. Je hebt er geen locks of synchronisatie omheen nodig. De states ervoor en erna worden opgeslagen en vergeleken om de change-diff te produceren. Bij een fout wordt de vorige state hersteld, waardoor alles wat in die transactie al was gedaan wordt teruggedraaid.

De wijzigingen worden door een achtergrondthread naar schijf geschreven, naar een append-only bestand dat eenvoudig te repareren is bij eventuele corruptie door stroomuitval of crashes.

Dat was ongeveer het punt waarop Kanta er niet meer als een database-experiment uitzag, maar als een coherent model om op te bouwen.

Waarom niet in productie proberen?

Na het uit te hebben geprobeerd met mijn eigen projecten, heb ik het al snel aangesloten op een serieuzere applicatie met zwaardere data en veel gebruikers. Dit beantwoordde mijn zorgen over mogelijke performanceproblemen, want we gebruikten immers feitelijk JSON als database, en wel in een logdb-structuur die anderen voor zover ik weet sinds de vroege dagen van de informatica niet meer hadden gebruikt.

Zoals verwacht werd het afspelen van grote changesets bij het opstarten van de applicatie na verloop van tijd traag, dus voegde ik volledige snapshot-regels toe om de lange replays vanaf de initiële revisie te vermijden. Daarna heeft de performance al mijn behoeften overtroffen.

Maar de kern is niet performance, het is eenvoud. Door aan te nemen dat onze app in één workerproces kan leven en de volledige state in zijn geheugen kan houden, verwijderen we de meeste problemen die met de typische database gepaard gaan.

Door de logstructuur komt het terugspoelen van geschiedenis gratis. Ik heb zelfs een reeks transacties midden in de geschiedenis ongedaan gemaakt — om een gebruiker te redden die een deel van het project had verwijderd en daarna verdere wijzigingen had gedaan.

Deze website is ook gebouwd op Kanta (Pagerite CMS).

Migraties

image
Inspect any slice through your database, grepping for the data you need

Tooling

Kanta also has a small CLI for inspecting databases directly. It can replay a database, inspect selected ranges of its history, load the application’s actual data types and run migrations when needed, and dump the resulting state as JSON. A good tool also beats treating it as a text file.

Het enkel-proces-model verwijdert een hoop machinerie, maar het verwijdert niet het feit dat software verandert.

Niemand heeft ooit van migraties gehouden. Ze zijn de last van het doorvoeren van onderhoudswijzigingen over data. Nog een SQL-migratie of weer een Mongo legacy-fallback en update-branch is gewoon te veel gedoe, dus vermijd je die wijzigingen liever.

Ook hier doet msgspec een groot deel van het zware werk. Als we een veld willen toevoegen of verwijderen, zetten we simpelweg het nieuwe veld met een standaardwaarde op de datastructuren, of verwijderen we een oud veld. Het migreert dan stilletjes naar het nieuwe formaat. Als het formaat dat wordt geladen niet overeenkomt met de structuren, krijgen we een foutmelding die zegt wat en waar er mis is.

Gaaf en simpel, maar niet voldoende voor een database.

Af en toe willen we een veld hernoemen, datatypes wijzigen of regelrecht de hele data herstructureren, mogelijk terwijl we onderweg nieuwe externe data ophalen (dat heb ik gedaan). Dit vereist een echte migratiefunctie die weet wat ze doet.

def migrate_v1(d: dict) -> None:
    """Rename counter to total"""
    d["total"] = d["counter"]

Het formaat is simpel: het databaserevisienummer komt direct uit de functienaam. De functie manipuleert het gewone dict-formaat, zodat we geen msgspec.Structs van oudere versies hoeven bij te houden. De docstring geeft de gelogde beschrijving van wat er is gedaan.

Om te voorkomen dat de rest van ons programma hiermee wordt bezaaid, kunnen de migraties in een eigen Python-module worden gezet, waarnaar we simpelweg verwijzen met het Python-modulepad bij het definiëren van de database:

kanta = Kanta("foo.kantadb", migrations="foo.migrations")

De changeset-metadata en snapshots bevatten het versienummer dat ze representeren, en we passen alle gevonden migrate-functies toe vanaf die versie omhoog, en voeren om de migratie te voltooien in elk geval de msgspec-conversie uit. Als dat iets heeft veranderd, loggen en slaan we de uitgevoerde migraties op, en maken daarna een snapshot.

De oudste migratiefuncties kunnen ook worden verwijderd, wanneer dergelijke versies niet meer ondersteund hoeven te worden, waardoor dit hele migratiesysteem beheersbaar blijft.

Formaat op schijf

Het standaardformaat blijft bewust ongecompliceerd: JSON-records, één per regel. Msgspec codeert bytes en andere types daarin.

Voor de publicatie wilde ik ingaan op een kritiek die ik zelf zeker zou geven over het gebrek aan binaire ondersteuning, door dat als optie te implementeren.

Terwijl we met JSONL vanaf het einde van het bestand naar newlines kunnen zoeken om een snapshot te vinden, zouden de binaire data de parser kunnen verwarren. We kunnen vanaf het begin van het bestand lezen voor een volledig deterministische aanpak, maar als de database groot zou zijn, zouden we liever nabij het einde zoeken. Ik wilde het ook append-only houden, dus we konden niet simpelweg aan het begin de snapshot-offset schrijven.

De oplossing is tweeledig:

  • Onvoorspelbare willekeurige nonce als syncwoord
  • Blake3-hash voor integriteitsverificatie

De change-payloads zelf worden momenteel gecodeerd met MessagePack, dat direct wordt ondersteund in msgspec.

Ik zou dat graag vervangen door iets dat dichter bij Protocol Buffers ligt, waarbij veldnamen en -types helemaal niet worden opgeslagen, gebruikmakend van het feit dat msgspec de structuur en types van mijn structs al kent, en als zodanig een compacte binaire representatie uit diezelfde informatie zou moeten kunnen afleiden.

Dat zou de eigenschap behouden waar ik het meest om geef: één definitie van de datastructuur, in plaats van een Python-model plus een ORM-model plus een serialisatieschema.

De database is slechts de helft van de state

Het in het geheugen houden van de gezaghebbende state maakt iets anders moeilijk te negeren: de meeste interactieve applicaties houden al ergens anders een kopie bij.

De browser heeft er ook een.

Zodra de database zelf een geordende stroom van wijzigingen vastlegt, wordt het gebruik van diezelfde stroom voor synchronisatie een voor de hand liggende volgende stap.

Stores synchroniseren over WebSocket

De FastAPI-app stuurt wijzigingen over een WebSocket en past inkomende wijzigingen weer toe op de database.

Op elke client houden we een schaduwkopie bij, dat is de laatst geziene server-state, en de huidige werkstate van de client in zijn native Vue Pinia-store. Of Svelte- of React-equivalenten.

De store is de verbindende schakel die reactiviteit biedt vanaf de database helemaal tot aan de gebruikersinterface en terug.

De client kan offline of gelijktijdig met andere clients werken, en de wijzigingen worden gemerged zodra ze op de server landen, met een three-way merge met automatische resolutie. Gecombineerd met overschrijven en validatie/afkeuring — we willen absoluut niet dat merge-conflicten een handmatige git-achtige oplossing vereisen.

Ik heb deze machinerie momenteel in applicaties ingebed in plaats van verpakt als onderdeel van Kanta. De volgende stap is het extraheren naar een aparte Kanta-compatibele synchronisatiemodule, met adapters ook voor de Javascript-frontends.

Dat is waarschijnlijk een ander artikel.

Probeer het eens

Al deze ontwerpkeuzes doen er alleen toe als ze gewone applicatiecode eenvoudiger maken.

Met Kanta is de database niets dat ik via een ander model bevraag. Ik definieer de state die ik wil, open die, en werk met normale Python-objecten. Reads zijn gewoon reads:

user = data.users[user_id]

Als ik iets wil veranderen, maak ik die wijziging binnen een transactie:

with kanta.transaction(action="update"):
    data.users[user_id].name = "Alice"

Er zit geen querytaal tussen, geen ORM-object om terug te vertalen naar mijn applicatiemodel, en geen aparte auditcode om te onthouden. De transactie, geschiedenis, rollback en persistentie komen allemaal uit dezelfde operatie.

uv add kanta

Voeg het toe aan je project met uv of lees meer op git.zi.fi.

image
De demo gaat bewust een stukje verder dan hello world. Hij maakt een database met één versie van het datamodel, wijzigt die via meerdere transacties, opent daarna dezelfde database met een nieuwer model, voert een migratie uit, demonstreert rollback en werkt er normaal mee verder. Dat is ongeveer het soort levenscyclus dat ik saai wilde maken.
uv run --with kanta demo.py

demo.py

import asyncio, msgspec
from kanta import Kanta, configure_logging

# For demonstration purposes, we use "original v0" and "modified v1" in this same script
# Normally your app would only have the latest supported data model

class Data(msgspec.Struct):  # type: ignore - intentionally redefined later
    users: dict[str, dict] = {}
    counter: int = 0

kanta_v0 = Kanta("demo.kantadb", Data())

@kanta_v0.bootstrap
def bootstrap(data: Data) -> None:
    """Create the initial admin user."""
    data.users["userid001"] = {"name": "Alice", "role": "admin"}


# Redefinition to simulate new version
class Data(msgspec.Struct):
    users: dict[str, dict] = {}
    total: int = 0  # Replaces old counter field
    lang: str = "en"  # New field

def migrate_v1(d: dict) -> None:
    """Rename counter to total"""
    d["total"] = d["counter"]

kanta_v1 = Kanta("demo.kantadb", Data(), migrations="demo")

@kanta_v1.logfmt
def resolve_user(value: str, path: str, state: dict) -> str | None:
    """Resolve user ids to names from the database state itself."""
    if path != "$user" and not path.startswith("users."):
        return None
    return state.get("users", {}).get(value, {}).get("name")


async def main() -> None:
    print("Database creation with v0 schema and basic transactions:\n")
    # Open and close automatically; you can also `await kanta.open()` instead
    async with kanta_v0 as kanta:
        with kanta.transaction(action="create", user="userid001") as data:
            data.users["userid002"] = {"name": "Bob", "role": "user"}

        with kanta.transaction(action="update", user="userid001") as data:
            data.users["userid002"]["role"] = "editor"
            data.counter = 1

        # Display-only extra string, appended after the action.
        with kanta.transaction(action="export", user="userid002", extra="(we are still v0)") as data:
            data.counter = 2

    print("\nA new data model, migrations and logfmt pretty names:\n")
    async with kanta_v1 as kanta:
        with kanta.transaction(action="update", user="userid002", extra="(new version)") as data:
            data.total += 1

        try:
            with kanta.transaction(action="reset", user="userid001") as data:
                data.total = 99
                raise ValueError("simulated failure")
        except ValueError:
            print(f"\nReset rolled back: {data.total=} (we can always read data without tx)\n")

        with kanta.transaction(action="delete", user="userid002") as data:
            data.users.pop("userid001", None)  # del if exists

if __name__ == "__main__":
    configure_logging(debug=True)
    asyncio.run(main())