AACsearch
Architektur

Schreibpfad

Wie ein Dokument-Write in AACsearch ankommt — von POST /v1/indexes/.../documents über SearchIngestBuffer / SearchSyncOutbox und den Worker bis in den Typesense-Alias.

AACsearch ist DB-first: Jeder Write landet zuerst in PostgreSQL, bevor er nach Typesense projiziert wird. Die HTTP-Schicht ruft Typesense niemals synchron aus einer Kundenanfrage heraus auf. Das ist Hard Invariant #2 — Haltbarkeit, Partial-Fail-Handling und Zero-Downtime-Reindex hängen davon ab.

Ablauf

Beschreibung

Das Diagramm zeigt einen aus Client-Sicht synchronen HTTP-Write, der authentifiziert, rate-limitiert und quota-geprüft wird und anschließend dauerhaft in PostgreSQL (SearchIngestBuffer / SearchSyncOutbox) eingereiht wird — der Kunde erhält sofort 202 Accepted. Ein Hintergrund-Worker übernimmt später die ausstehenden Zeilen, hängt Embeddings an und importiert den Batch in den Typesense-Alias, wobei Erfolg oder Fehlschlag pro Zeile mit exponentiellem Backoff abgeglichen wird.

sequenceDiagram
    autonumber
    participant Client as Customer / Connector
    participant API as packages/api/v1/documents.ts
    participant Auth as verifySearchApiKey (scope=ingest)
    participant DB as PostgreSQL (SearchIngestBuffer + SearchSyncOutbox)
    participant W as Sync worker (sync-worker.ts)
    participant Embed as autoEmbedDocuments
    participant TS as Typesense alias_name(orgShortId_slug)

    Client->>API: POST /v1/indexes/:indexId/documents:batch
    API->>Auth: Bearer ss_search_* / ss_connector_*
    Auth-->>API: VerifiedSearchKey { organizationId, indexId }
    API->>API: rate-limit (per-key, 1m sliding bucket)
    API->>API: enforceQuota (plan / overage)
    API->>DB: enqueueManySearchIngest() (or SearchSyncOutbox doc_upsert)
    API-->>Client: 202 Accepted (jobId)

    loop worker tick
        W->>DB: claim pending rows (atomic updateMany + lockedBy)
        W->>Embed: autoEmbedDocuments(batch)
        Embed-->>W: vectors attached
        W->>TS: collection.documents().import(batch, action=upsert)
        alt all green
            W->>DB: markIngestRowsSuccess / outbox.status=done
        else partial fail
            W->>DB: markIngestRowsFailure + nextRetryAt (exp. backoff)
        end
    end

Was jeder Schritt garantiert

  1. Auth (verifySearchApiKey). Das Token wird gehasht (sha256) und gegen die Spalte SearchApiKey.hash verglichen. Connector-Schlüssel (ss_connector_*) und Search-Schlüssel (ss_search_*) teilen sich den Hash-Raum; die scopes-Spalte bestimmt die Autorisierung.
  2. Rate Limit. Pro-Schlüssel-Sliding-Window aus SearchRateLimitBucket. Ein Überschreiten von rateLimitPerMinute liefert 429 vor jedem DB-Write.
  3. Quota-Gate. Plan-Entitlements und Wallet-Overage werden einmal pro Request geprüft; das Schreib-Quota wird atomar mit dem Enqueue verbraucht.
  4. Haltbares Enqueue. Zeilen werden in SearchIngestBuffer (Legacy-Pfad) oder SearchSyncOutbox (kanonischer, idempotenter Pfad) geschrieben. Die HTTP-Antwort ist 202 — das Dokument muss nicht in Typesense sein, bevor der Client zurückkehrt.
  5. Worker-Projektion. Ein Hintergrundprozess klaut Zeilen mit lockedBy = WORKER_ID, führt Auto-Embedding aus, wenn der Index ein Vektorfeld hat, ruft collection.documents().import() auf und versöhnt Erfolg / Fehlschlag pro Zeile.
  6. Alias-Ziel. Der Worker schreibt immer in aliasName(organizationId, slug), das auf die aktuelle physische Collection-Version zeigt. Ein Reindex tauscht den Alias atomar; laufende Writes folgen dem neuen Pointer.

Warum DB-first

  • Haltbarkeit. Server-Neustarts oder Typesense-Ausfälle verlieren keine Writes.
  • Partial-Fail-Recovery. Nur fehlgeschlagene Zeilen wiederholen; erfolgreiche werden nicht dupliziert.
  • Mandanten-Isolation. Der Worker markiert jedes Dokument mit dem Feld tenantId vor dem Import; der Alias erzwingt filter_by beim Lesen.
  • Backpressure. Der Buffer absorbiert stoßweise CMS-Full-Syncs, ohne Typesense zu überlasten.

Verwandt

On this page