Zum Inhalt springen
Deutsch

Object Storage im Überblick

Das Object-Storage-Backend des Frameworks ist eine S3-kompatible Persistenz-Schicht. Zwei Implementierungen werden mitgeliefert:

BackendVerwendung
FilesystemObjectStorageBackendLokale Dateien; Dev + Tests.
S3ObjectStorageBackendAlles S3-kompatible (AWS S3, MinIO, R2, B2).

Verwendet von:

  • ObjectStorageDurableStateStore — Durable State im Cloud-Storage.
  • ObjectStorageSnapshotStore — Snapshots im Cloud-Storage.

Gebaut auf einem kleinen Interface (PUT / GET / DELETE / LIST + CAS-Unterstützung); beide Backends sprechen dieselbe Oberfläche.

Du solltest Object Storage verwenden, wenn…
Du Cloud-native bist und S3 (oder ähnlich) deine Storage-Plattform ist.
Du geteilte Persistenz über Cluster-Nodes willst, ohne Cassandra zu betreiben.
Du Server-Side-Encryption über Cloud-KMS willst.
Du unendliches Scaling willst, ohne Storage-Kapazität zu verwalten.

Für Single-Node-Deployments ist SQLite einfacher. Für High-Throughput-Multi-Node-Persistenz ist Cassandra schneller. Object Storage sitzt dazwischen — cloud-freundlich, anständige Performance, viele Features.

import {
DurableStateOptions,
ObjectStorageDurableStateStore,
ObjectStorageDurableStateStoreOptions,
S3ObjectStorageBackend,
S3ObjectStorageOptions,
} from 'actor-ts/persistence';
const s3ObjectStorageOptions = S3ObjectStorageOptions.create()
.withRegion('eu-west-1')
.withBucket('my-app-state');
const backend = new S3ObjectStorageBackend(s3ObjectStorageOptions);
const objectStorageDurableStateStoreOptions = ObjectStorageDurableStateStoreOptions.create().withBackend(backend);
const stateStore = new ObjectStorageDurableStateStore(
objectStorageDurableStateStoreOptions,
);
const durableStateOptions = DurableStateOptions.create<State>()
.withPersistenceId(`cart-${userId}`)
.withStore(stateStore)
.withEmptyState(() => ({ items: [] }));
const cart = system.spawnAnonymous(() => new Cart(durableStateOptions));

Der State von Cart lebt in S3 unter cart-<userId> als Object-Key. Reads und Writes gehen durch die S3-API.

Object-Keys folgen einem vorhersagbaren Layout:

state/
cart-user-42 # ein Object pro persistenceId
cart-user-43
snapshots/
account-42/
seq-100 # Snapshots indexiert nach seqNr
seq-200
seq-300

Das Framework verwaltet dieses Layout; du baust keine Keys manuell.

interface ObjectStorageBackend {
put(key: string, body: Uint8Array, options?: PutOptions): Promise<{ etag: string }>;
get(key: string): Promise<Option<ObjectFetched>>;
delete(key: string): Promise<void>;
list(options: { prefix: string; limit?: number }): Promise<ObjectInfo[]>;
}

Kleine Oberfläche — passt zu AWS S3, MinIO, Cloudflare R2, Backblaze B2, Wasabi, etc. Die meisten S3-kompatiblen APIs passen exakt.

await backend.put('state/cart-42', body, {
ifMatch: 'previous-etag',
});

ifMatch lässt Aufrufer Compare-and-Set-Writes machen — wenn das aktuelle ETag abweicht, schlägt das Put mit ObjectStorageConcurrencyError fehl. Verwendet von ObjectStorageDurableStateStore, um nebenläufige Writer ohne separate Koordination zu erkennen.

ifNoneMatch: '*' ist Create-only — gelingt nur, wenn der Key noch nicht existiert.

Einige ältere S3-kompatible Stores beachten diese Header nicht richtig. Die Backend-Implementierungen des Frameworks werfen klar in diesem Fall, anstatt still zu ignorieren; prüfe die CAS-Unterstützung deines Providers, bevor du dich darauf verlässt.

import { FilesystemObjectStorageBackend, FilesystemObjectStorageOptions } from 'actor-ts/persistence';
const filesystemObjectStorageOptions = FilesystemObjectStorageOptions.create().withDir('/var/lib/actor-ts');
const backend = new FilesystemObjectStorageBackend(
filesystemObjectStorageOptions,
);

Speichert Objects als Dateien unter rootDir. Kein Netzwerk, keine S3-Kosten. Richtig für:

  • Tests — gleicher Code-Pfad wie Produktion, mit lokalen Dateien.
  • Lokales Dev — kein MinIO-Container erforderlich.
  • Kleine Single-Node-Deployments — wenn du speziell das Object-Storage-Interface ohne S3 willst.

Das Root-Verzeichnis ist die gesamte Welt dieses Backends. Ein Key mit .., absolutem Präfix oder NUL-Byte wird direkt abgelehnt, und jede Operation kanonisiert zusätzlich den Pfad, den sie gleich anfasst — ein Symlink, der im Root liegt und aus ihm herauszeigt, wird also abgelehnt statt verfolgt. Das Root selbst darf ein Symlink sein: jede Operation löst es auf und misst das Containment am Ergebnis, /var/lib/app -> /mnt/data funktioniert also normal.

„Der Pfad, den sie gleich anfasst“ meint die Objektnamen: den Key selbst, die Verzeichnisse darüber und seinen .meta.json-Sidecar. Der Key und seine Eltern werden aus unterschiedlichen Gründen geprüft, und keine der beiden Prüfungen deckt die andere ab — rename folgt nie einem Link an der letzten Komponente, ein verlinktes Elternverzeichnis verschiebt also, wo ein Body landet, während get und das Compare-and-Swap von put den Key beide mit readFile öffnen, und das folgt sehr wohl einem Link.

Drei Grenzen sollte man kennen, denn sonst nimmt man mehr an, als die Prüfung leistet. Sie liest das Dateisystem und handelt danach, lehnt also einen bereits vorhandenen Link ab, kann aber das Rennen gegen einen nicht gewinnen, der genau dazwischen gelegt wird — portables O_NOFOLLOW ist über node:fs nicht erreichbar. Sie schließt die eigenen Operationen des Backends ein; sie ist keine Sandbox um ein Verzeichnis, in das andere lokale Prozesse schreiben dürfen. Wenn nicht vertrauenswürdige Nutzer Einträge im Storage-Root anlegen können, ist das bereits das größere Problem.

Und sie deckt die Lock-Datei pro Key nicht ab. Das Nehmen des Locks ist ein exklusives Create, das einen Link ablehnt statt ihm zu folgen — aber die Stale-Lock-Wiederherstellung, die nach Ablauf des Acquisition-Timeouts läuft, macht ein stat auf diesen Namen, und stat folgt sehr wohl einem Link. Ein an <key>.lock platzierter Link lässt damit eine Datei außerhalb des Roots entscheiden, ob das Lock als verwaist gilt, und ein toter Link dort hält put und delete auf genau diesem Key im Retry, statt sie zurückkehren zu lassen.

import { S3ObjectStorageBackend, S3ObjectStorageOptions } from 'actor-ts/persistence';
const s3ObjectStorageOptions = S3ObjectStorageOptions.create()
.withRegion('eu-west-1')
.withBucket('my-app-state')
.withEndpoint('https://s3.eu-west-1.amazonaws.com') // optionaler Override
.withCredentials({
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
});
const backend = new S3ObjectStorageBackend(s3ObjectStorageOptions);

Funktioniert mit jedem S3-kompatiblen Service. Für Nicht-AWS:

// MinIO:
const s3ObjectStorageOptions = S3ObjectStorageOptions.create()
.withRegion('us-east-1')
.withBucket('my-bucket')
.withEndpoint('http://minio:9000')
.withForcePathStyle(true);
new S3ObjectStorageBackend(s3ObjectStorageOptions);
// Cloudflare R2:
const s3ObjectStorage2Options = S3ObjectStorageOptions.create()
.withRegion('auto')
.withBucket('my-bucket')
.withEndpoint('https://<account-id>.r2.cloudflarestorage.com');
new S3ObjectStorageBackend(s3ObjectStorage2Options);
// Backblaze B2:
const s3ObjectStorage3Options = S3ObjectStorageOptions.create()
.withRegion('us-west-002')
.withBucket('my-bucket')
.withEndpoint('https://s3.us-west-002.backblazeb2.com');
new S3ObjectStorageBackend(s3ObjectStorage3Options);
FeatureSeite
Kompression (gzip / zstd)Kompression
Verschlüsselung at rest (AES-GCM)Verschlüsselung
Body-Integrität (HMAC-SHA256)Body-Integrität
Storage-Key-Bindung + Rollback-SchutzEinen Body an seinen Key binden
Master-Key-RotationSchlüsselrotation
Per-Actor-Kompressions- / Verschlüsselungs-PoliciesPer-Actor-Policies
Snapshot-Store-BackendSnapshot-Store-Backend

Alle optional — starte ohne; schichte nach Bedarf darauf.

Grobe Zahlen für S3:

  • Put (kleines Objekt): 20-50 ms.
  • Get: 10-30 ms.
  • Delete: 30-50 ms.

Filesystem-Backend: Sub-Millisekunde.

Ein LIST im Filesystem-Backend liest nur das Verzeichnis, das sein Prefix benennt — alles bis zum letzten / des Prefix —, kostet also den getroffenen Teilbaum und nicht das gesamte Storage-Root. Genau das hält ein Prefix pro persistenceId wie snapshots/<pid>/ im Sub-Millisekunden-Bereich, egal wie viele andere Entities sich das Root teilen. Ein Prefix ohne / (snapshots) hat kein Verzeichnis, auf das es einschränken könnte, und läuft weiterhin über alles — bevorzuge daher Keys mit einem Verzeichnis-Segment pro Entity.

Object Storage ist langsamer als SQLite / Cassandra für einzelne Operationen. Kompensiere mit:

  • Snapshot-Policies, die die Recovery begrenzen.
  • CachedSnapshotStore-Decorator für Read-Through-Caching.
  • Batching, wo immer das Framework es erlaubt.