Pular para o conteúdo
Português (BR)

Object storage overview

Este conteúdo não está disponível em sua língua ainda.

The framework’s object-storage backend is an S3-compatible persistence layer. Two implementations ship:

BackendUse
FilesystemObjectStorageBackendLocal files; dev + tests.
S3ObjectStorageBackendAnything S3-compatible (AWS S3, MinIO, R2, B2).

Used by:

  • ObjectStorageDurableStateStore — durable state in cloud storage.
  • ObjectStorageSnapshotStore — snapshots in cloud storage.

Built on a small interface (PUT / GET / DELETE / LIST + CAS support); both backends speak the same surface.

You should use object storage when…
You’re cloud-native and S3 (or similar) is your storage platform.
You want shared persistence across cluster nodes without running Cassandra.
You want server-side encryption via cloud KMS.
You want infinite scaling without managing storage capacity.

For single-node deployments, SQLite is simpler. For high-throughput multi-node persistence, Cassandra is faster. Object storage sits in between — cloud-friendly, decent performance, lots of 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));

Cart’s state lives in S3 under cart-<userId> as the object key. Reads and writes go through the S3 API.

Object keys follow a predictable layout:

state/
cart-user-42 # one object per persistenceId
cart-user-43
snapshots/
account-42/
seq-100 # snapshots indexed by seqNr
seq-200
seq-300

The framework manages this layout; you don’t construct keys manually.

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[]>;
}

Small surface — fits AWS S3, MinIO, Cloudflare R2, Backblaze B2, Wasabi, etc. Most S3-compatible APIs match exactly.

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

ifMatch lets callers do compare-and-set writes — if the current ETag differs, the put fails with ObjectStorageConcurrencyError. Used by ObjectStorageDurableStateStore to detect concurrent writers without separate coordination.

ifNoneMatch: '*' is create-only — succeed only if the key doesn’t exist yet.

Some older S3-compatible stores don’t honor these headers properly. The framework’s backend implementations throw clearly in that case rather than silently ignoring; check your provider’s CAS support before relying on it.

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

Stores objects as files under rootDir. No network, no S3 cost. Right for:

  • Tests — same code path as production, with local files.
  • Local dev — no Minio container required.
  • Small single-node deployments — if you specifically want the object-storage interface without S3.

The root directory is the whole of this backend’s world. A key carrying .., an absolute prefix or a NUL byte is rejected outright, and every operation additionally canonicalises the path it is about to touch — so a symlink sitting inside the root and pointing out of it is refused rather than followed. The root itself may be a symlink: each operation resolves it and measures containment against the result, so /var/lib/app -> /mnt/data works normally.

“The path it is about to touch” means the object names: the key itself, the directories above it, and its .meta.json sidecar. The key and its parents are checked for different reasons, and neither check covers the other — rename never follows a link at the final component, so a linked parent is what moves where a body lands, while get and put’s compare-and-swap both open the key with readFile, which does follow one.

Three limits are worth knowing, because the alternative is assuming more than the check delivers. It reads the filesystem and then acts on it, so it refuses a link that is already in place but cannot win a race against one planted in between — portable O_NOFOLLOW is not reachable through node:fs. It confines the backend’s own operations; it is not a sandbox around a directory other local processes may write to. If untrusted users can create entries inside the storage root, that is already the larger problem.

And it does not cover the per-key lock file. Taking the lock is an exclusive create, which refuses a link rather than following it — but the stale-lock recovery that runs once the acquisition timeout is exhausted stats that name, and stat does follow one. So a link planted at <key>.lock lets a file outside the root decide whether the lock is treated as abandoned, and a dangling link there keeps put and delete on that one key retrying instead of returning.

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') // optional override
.withCredentials({
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
});
const backend = new S3ObjectStorageBackend(s3ObjectStorageOptions);

Works with any S3-compatible service. For non-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);
FeaturePage
Compression (gzip / zstd)Compression
Encryption at rest (AES-GCM)Encryption
Body integrity (HMAC-SHA256)Body integrity
Storage-key binding + rollback protectionBinding a body to its key
Master-key rotationKey rotation
Per-actor compression / encryption policiesPer-actor policies
Snapshot store backendSnapshot store backend

All optional — start without; layer on as needed.

Rough numbers for S3:

  • Put (small object): 20-50 ms.
  • Get: 10-30 ms.
  • Delete: 30-50 ms.

Filesystem backend: sub-millisecond.

A LIST on the filesystem backend reads only the directory its prefix names — everything up to the prefix’s last / — so it costs the matched subtree, not the whole storage root. That is what keeps a per-persistenceId prefix such as snapshots/<pid>/ sub-millisecond however many other entities share the root. A prefix with no / in it (snapshots) has no directory to narrow to and still walks everything, so prefer keys with a per-entity directory segment.

Object-storage is slower than SQLite / Cassandra for single operations. Compensate with:

  • Snapshot policies that bound recovery.
  • CachedSnapshotStore decorator for read-through caching.
  • Batching wherever the framework allows.