Object storage is alles wat je nodig hebt

Context

Tigris biedt object storage aan via een database-engine die is gebouwd bovenop FoundationDB, een gedistribueerde key-value store. JP, de oprichter van Ampbase, gebruikt een control plane op Tigris zonder een onderliggende database. In dit artikel legt hij uit welke database-functies hij zelf heeft moeten bouwen en wat de kosten daarvan zijn.

---

In een vorige post op de Ampbase-blog besprak ik alle database-engines die we niet gebruiken. Ik beloofde toen uit te leggen wat we wél doen. We gebruiken geen relationele database; we gebruiken Tigris direct als storage-laag en implementeren de weinige database-functies die we echt nodig hebben bovenop de twee primitieven die Tigris ons biedt.

Ik weet dat "we hadden geen database nodig" een pakkende titel is die meestal ongeveer acht maanden voorafgaat aan een volgende post met de titel "hoe we onze staart tussen de benen staken en overstapten naar Postgres".

In de praktijk zoek je bij een database-engine eigenlijk naar vier basisfunctionaliteiten: unique constraints, transacties, indexen en geschiedenis-tabellen (history tables). Om de globale object storage van Tigris als database te kunnen gebruiken, moesten we al deze primitieven zelf implementeren. Hieronder leg ik uit hoe deze primitieven werken, zodat je begrijpt wat er feitelijk in je favoriete database-engine gebeurt.

Wat er daadwerkelijk wordt opgeslagen

Alles bevindt zich in twee lagen van buckets.

1. De globale directory-bucket (één globale bucket) Deze bevat de lijst met organisaties. Binnen deze bucket worden specifieke taken uitgevoerd die normaal door een database worden afgehandeld:

  • orgs/{org_id}/metadata.json
  • orgs/{org_id}/members/{sha256(email)}.json (De index)
  • orgs/{org_id}/billing.json (Het doel voor compare-and-swap)
  • orgs/{orgid}/channels/{channelid}/metadata.json
  • orgs/{orgid}/api-tokens/{tokenid}.json
  • orgs/{orgid}/events/audit/{eventulid}.pb (Het audit-log)
  • org-ops/queue.pb

2. De org-bucket (één per klant) Deze bucket is gescopet naar de specifieke klant:

  • channel-slugs/{slug}.json (De unique constraint)
  • channel-{channelid}/config-meta/{configid}.json
  • channel-{channelid}/config-versions/{versionulid}.json (De geschiedenis-tabel)
  • channel-{channelid}/bundle-meta/{bundleid}.pb
  • channel-{channelid}/bundle-versions/{bundleid}/{version_ulid}.pb
  • channel-{channel_id}/active-config.pb (Een pointer, die ter plaatse wordt overschreven)
  • channel-{channelid}/events/{eventulid}.json

In het begin schreven we alles als JSON-objecten naar de bucket van de klant. Later zijn we overgestapt naar Protocol Buffers (Protobuf) om validatie naast de schema-definitie te kunnen definiëren en omdat het marshallen van JSON duurder bleek dan verwacht. Onze database ondersteunt beide formaten, zodat oude records nog steeds correct worden geladen.

Door Protobuf te gebruiken, elimineren we het beheer van een database-laag: migraties, verbindingen en schema's. Het enige nadeel is dat Protobuf-veldnamen permanent zijn, maar dat is vergelijkbaar met het wijzigen van kolomnamen in Postgres, MySQL of SQLite.

Isolatie

In plaats van één grote bucket met prefixes te gebruiken, maken we gebruik van het Partner Integration Program van Tigris. Eén aanroep creëert een Tigris-organisatie voor de klant, een eigen bucket en een set access keys.

Isolatie is ingebakken in de infrastructuurlaag. Er is geen risico op het vergeten van een WHERE org_id = ? in de applicatiecode, omdat de credentials van één klant simpelweg geen toegang hebben tot de data van een andere klant.

Database-gedrag met de eenvoud van object storage

Om object storage als database te gebruiken, leunen we op sterke read-after-write consistentie, conditionele writes en andere primitieven. De belangrijkste garanties die je van een database nodig hebt, zijn:

  1. Sterke read-after-write consistentie
  2. Conditionele writes
  3. Uniqueness constraints
  4. Transacties
  5. Indexen
  6. Geschiedenis-tabellen

Tigris biedt de eerste twee standaard; de overige vier hebben we zelf geïmplementeerd.

Sterke read-after-write consistentie

Object storage had pas rond december 2020 een sterk consistentiemodel. Tigris zorgt ervoor dat bucket-data globaal is, niet alleen de bucket-namen. Dit betekent dat data sterk consistent is wanneer clients zich in dezelfde regio bevinden. Zodra je regio's overstijgt, wordt het complexer: globale replicatie betekent eventual consistency.

Conditionele writes

Conditionele writes zijn in feite compare-and-swap. Tigris ondersteunt HTTP-precondities bij writes:

  • If-None-Match: *: Schrijft alleen als de key nog niet bestaat.
  • If-Match: {etag}: Schrijft alleen als het object niet is gewijzigd sinds de laatste lezing.

Uniqueness zonder UNIQUE

Een index heeft twee hoofdfuncties: het versnellen van lookups en het voorkomen van dubbele data (CREATE UNIQUE INDEX). Om uniqueness te garanderen, gebruiken we conditionele writes.

We proberen kanalen of gebruikers aan te maken met de If-None-Match: * header. Als twee applicatie-instanties tegelijkertijd verschillende data naar dezelfde plek schrijven, bepaalt Tigris wie wint. De verliezer krijgt een foutmelding:

switch {
case err == nil:
    return nil
case isPreconditionFailed(err):
    // Een andere writer heeft de slug gelijktijdig aangemaakt.
    // Opnieuw lezen om te bepalen of dit idempotent is (dezelfde channelID)
    // of een conflicterende mapping.
    return s.handlePutConflict(ctx, slug, channelID, err)
}

Mutatie zonder transacties

Voor zaken als facturatie (billing state) gebruiken we optimistic concurrency: lees het object en de ETag, bereken de nieuwe staat, schrijf deze terug op voorwaarde dat de ETag nog steeds matcht, en herhaal het proces vanaf een nieuwe lezing als dat niet lukt.

func precondition(etag string) (ifMatch, ifNoneMatch *string) {
    switch etag {
    case "":
        return nil, aws.String("*")
    default:
        return aws.String(etag), nil
    }
}

switch _, err := s.client.PutObject(ctx, in); {
case isPreconditionFailed(err):
    return nil, err // opnieuw proberen vanaf een verse read
}

Een belangrijke beperte is dat de mutatie-functie een pure functie moet zijn. Eventuele side-effects (zoals het versturen van een e-mail of een Stripe-aanroep) zouden bij elke retry opnieuw gebeuren.

Indexen zonder index

Een pad als members/{sha256(email)}.json is in feite een index. De vraag "is deze gebruiker lid van deze organisatie?" wordt beantwoord met één GetObject op een pad dat lokaal kan worden berekend. Geen scans, geen listing, geen secundaire indexen. De key is de lookup. Dit betekent echter dat je alleen vragen kunt beantwoorden waarvoor je vooraf een key hebt ontworpen (O(1) toegang).

Geschiedenis zonder geschiedenis-tabel

Configuratieversies worden append-only opgeslagen onder ULID-keys. Omdat ULID's lexicografisch sorteerbaar zijn op creatietijd en object storage keys in diezelfde volgorde lijst, is een lijst van alle wijzigingen simpelweg een prefix-list.

Er is geen UPDATE mogelijk, alleen het toevoegen van nieuwe versies. Wat wel wordt overschreven, zijn de pointers (bijv. welke versie momenteel actief is). Rollbacks zijn simpelweg het verplaatsen van de pointer terug naar een oude versie die nooit is verwijderd.

Waar het daadwerkelijk vastloopt

Deze architectuur werkt goed op een whiteboard, maar kent in de praktijk pijnpunten.

Read amplification is de echte kostprijs

De control plane draait op meerdere instanties per regio. Elke instantie leest bij elk request de directory-bucket voor token-validatie, metadata en RBAC. Bij $N$ instanties betaal je elke read $N$ keer. Nog erger zijn de fan-outs: het ophalen van leden of API-tokens vereist één ListObjectsV2 gevolgd door meerdere GetObject aanroepen. In Postgres zou dit één query met een join zijn.

De oplossing is een read-through cache (zoals Valkey, DragonflyDB, Memcached of Redis). De cache is echter nooit de source of truth; hij verkort alleen de latency.

Beperkingen in query's

Er zijn geen joins of ad-hoc queries. Elke toegangspatroon vereist een vooraf gekozen key. Een nieuwe vraag betekent een nieuwe key, wat resulteert in een handmatige backfill-job in plaats van een simpele CREATE INDEX.

Het event-log is geen source of truth

Hoewel onze layout lijkt op event sourcing, is het eerder een audit-log. Omdat object storage geen transacties over meerdere objecten ondersteunt, kunnen we niet garanderen dat zowel het toevoegen van een event als het bijwerken van de status atomair gebeurt. We schrijven eerst de status en dan het event. Als een proces daartussen crasht, is de status correct maar ontbreekt het event in de geschiedenis.

Regionale consistentie en conditionele writes

Bij globale replicatie moet je rekening houden met eventual consistency. Conditionele writes zijn afhankelijk van wat de lokale regio denkt dat de huidige staat is. Dit kan leiden tot een scenario waarbij twee verschillende regio's beide een compare-and-swap operatie accepteren, maar na convergentie van het systeem één van de writes simpelweg verdwijnt (last write wins).

Om dit op te lossen, sturen we RPC's die afhankelijk zijn van een specifieke status naar één primaire regio.

De uitzondering: Analytics

Voor analytics is het object-storage-only design niet voldoende. Onze oorspronkelijke opzet met ClickHouse was te complex en vormde een liability. We zijn overgestapt op een model waarbij de data-reductie op de host (de agent) plaatsvindt.

De flow ziet er nu als volgt uit:

  1. Customer Host: De supervisor reduceert frames naar sketches, masked templates en rollups per agent per 60 seconden.
  2. Tigris Bucket: Deze data wordt append-only opgeslagen als .pb bestanden.
  3. DuckDB: De reader gebruikt DuckDB om direct over deze bestanden te selecteren via httpfs.

Hiermee is de analytics-pipeline nu ook volledig gescoped per klant, waardoor de data van één klant niet toegankelijk is voor een andere.

Geen spijt, vraag me over een jaar

Ampbase kan zonder database omdat onze workload een specifieke vorm heeft: writes zijn laag-volume en zelden concurrerend, reads zijn point-lookups op keys die we controleren, en de data is netjes gepartitioneerd per organisatie.

Als we plotseling duizenden writes per seconde zouden hebben, of transacties over meerdere objecten nodig hadden, zou dit model falen. In dat geval zal ik een blogpost plaatsen over onze migratie naar Postgres.

Alternatieve tijdlijnen

Ik heb overwogen om SQLite per tenant te gebruiken met Litestream, maar ik wilde niet verantwoordelijk zijn voor de replicatie. Tigris regelt de replicatie voor mij.

Tien jaar geleden was dit onmogelijk. Zonder conditionele writes kun je geen uniqueness of veilige mutaties bouwen op een store. De stelling "je hebt hier echt Postgres voor nodig" is door recente updates in S3 en Tigris minder waar geworden.

Mijn advies is: de vorm van je data is de doorslaggevende factor bij het kiezen van je database. We hebben dit niet gedaan om interessant te zijn, maar omdat de data-laag de plek is waar kleine teams hun avonden verliezen. Dit is de versie met de minste bewegende delen.