Skip to content

Objects a service keeps

Some of what a service holds is not a record: a listing's photos, a signed contract as a PDF, the export a customer asked for. Kept in an entity, those bytes would be in every event, every snapshot and every view built from them. They belong in a bucket, and the entity keeps the object's name.

The storage client is how a service reaches its buckets. It reads like a file system and behaves like an object store: an object has a name made of segments separated by /, a content type and a length, and it is kept, read, listed, described and deleted whole. Where the bucket lives is the platform's to decide: the installation's own store, Google Cloud Storage, or a store you bring. On your machine it is a directory, and in a test it is memory. Your code is the same in every case.

Who has the client

The client is offered to endpoints, workflow steps, consumers, timed actions and agents. It is not offered to an entity or a view: their contexts have no storage at all, so in Scala reaching for it does not compile, in Python, TypeScript and Rust the attribute does not exist, and in Go ankka.StorageFrom(ctx) answers nil for an entity's or a view's context. A put or a get waits on the store, which an entity's single-writer path must not do. An entity records the name; whatever handled the bytes calls the entity with it.

A workflow's command handlers and its steps share one context, so a workflow's client answers only in a step; a command handler that calls it is refused.

The buckets a service has

A service has the unnamed bucket when its descriptor sets provisionObjectStorage, and a named bucket for each entry under buckets:

{
  "provisionObjectStorage": true,
  "buckets": [
    { "name": "photos", "exposure": "public" },
    { "name": "exports", "exposure": "signed" }
  ]
}

storage.default is the unnamed bucket, and storage.bucket("photos") a named one. A name the descriptor does not give is refused as Unavailable, naming the buckets it does give. Each bucket has an exposure, which decides what can be read from outside the service:

Exposure What it means
private only the service reaches it
signed the service signs URLs a browser uploads to or downloads from
public anyone reads an object at its address, with no signature
supplied a bucket the service brings, with its own variables

What the platform makes for each, and the variables it gives, are in Object storage on the platform.

Keeping and reading

Ten operations, by name:

  • put(name, body, length, contentType) keeps the object, replacing what was there. The body is a stream, passed to the store as it is read, so an object of any size is never held whole. A put declares its length.
  • putBytes(name, bytes, contentType) keeps an object you already hold.
  • get(name) answers the object as a stream with its content type, length and the time it was kept, or nothing when none is kept. getBytes(name) answers it read whole.
  • delete(name) removes the object. Removing nothing is not an error.
  • list(prefix) answers the names under a prefix, in order, with each length and the time it was kept. An empty prefix lists everything.
  • stat(name) answers the content type, length and time kept, or nothing.
  • signedUrl(name, access, span) answers a URL that reads the object, or keeps one under that name, until the span ends. A signed bucket's only.
  • url(name) answers the address anyone reads the object at, with no signature. A public bucket's only.

Each call blocks the handler until the store has answered. The components that have a client run on virtual threads (Scala) or call through their own runtime (Python, TypeScript), so the wait holds nothing else up.

An entity that records a photo's name, in Scala:

/** What a listing records about a photo: its name in the bucket, never its bytes. */
final case class PhotoRecorded(name: String)

final case class ListingState(photos: Vector[String])

final class Listing extends EventSourcedEntity[ListingState, PhotoRecorded]:
  def emptyState: ListingState = ListingState(Vector.empty)

  def applyEvent(event: PhotoRecorded): ListingState =
    currentState.copy(photos = currentState.photos :+ event.name)

  def recordPhoto(name: String): Effect[Done] =
    effects.persist(PhotoRecorded(name)).thenReply(_ => Done)

  def photos: ReadOnlyEffect[ListingState] = effects.reply(currentState)

private val listingSerializer: Serializer[ListingState] = Codecs.serializer[ListingState]("listing")

object Listing
    extends EventSourcedEntity.Companion[Listing, ListingState, PhotoRecorded](
      componentId = ComponentId("listing"),
      stateSerializer = listingSerializer,
      eventSerializer = Codecs.serializer[PhotoRecorded]("photo-recorded")
    ):
  given Serializer[ListingState]                 = listingSerializer
  def create(context: EventSourcedEntityContext) = new Listing

  val recordPhoto = command("record-photo")(_.recordPhoto)
  val photos      = query("photos")(_.photos)

And an endpoint over the service's buckets. The upload route passes the request's body to the bucket as it arrives and the download route writes the object as it is read; the last route keeps the bytes and gives the entity the name:

/** An endpoint over the service's buckets: keep, read, list, describe, delete, sign and address. */
final class PhotosEndpoint(clients: EndpointClients) extends HttpEndpoint("/storage"):

  val acl: Acl = Acl.AllowAll

  private def bucket: Bucket = chosen(clients.storage, query.optional[String]("bucket"))

  postBody("/objects") { (request: KeepObject) =>
    keep(clients.storage, request)
    Done: Done
  }

  // An upload passed to the bucket as it arrives, never held whole: any size the route admits.
  putStream("/upload") { (body: StreamedBody) =>
    val length =
      body.length.getOrElse(throw HttpProblem.badRequest("an upload declares its length"))
    bucket.put(query.required[String]("name"), body.body, length, body.contentType)
    Done: Done
  }

  // A download written as it is read from the bucket.
  get("/download") { () =>
    val name = query.required[String]("name")
    bucket
      .get(name)
      .fold(throw HttpProblem.notFound(s"no object '$name'"))(o =>
        Streamed(o.contentType, Some(o.length), o.body)
      )
  }

  get("/objects") { () =>
    val name = query.required[String]("name")
    bucket
      .getBytes(name)
      .fold(throw HttpProblem.notFound(s"no object '$name'"))((contentType, body) =>
        Bytes(contentType, body)
      )
  }

  get("/stat") { () =>
    val name = query.required[String]("name")
    bucket
      .stat(name)
      .fold(throw HttpProblem.notFound(s"no object '$name'"))(info =>
        Described(info.name, info.contentType, info.length, info.lastKept.toString)
      )
  }

  get("/list") { () =>
    Listed(
      bucket
        .list(query.optional[String]("prefix").getOrElse(""))
        .map(e => ListedObject(e.name, e.length, e.lastKept.toString))
        .toVector
    )
  }

  delete("/objects") { () =>
    bucket.delete(query.required[String]("name"))
    Done: Done
  }

  get("/signed") { () =>
    val access = query.required[String]("access") match
      case "keep" => SignedAccess.Keep
      case _      => SignedAccess.Read
    val span = query.optional[Int]("seconds").fold(ObjectRules.DefaultSignedUrl)(_.seconds)
    bucket.signedUrl(query.required[String]("name"), access, span).toString
  }

  get("/url") { () =>
    bucket.url(query.required[String]("name")).toString
  }

  // The entity keeps the name; the bucket keeps the object.
  postBody("/listings/{id}/photos") { (id: String, request: RecordPhoto) =>
    clients.storage.default.putBytes(
      request.name,
      request.text.getBytes(UTF_8),
      request.contentType
    )
    clients.componentClient
      .forEventSourcedEntity(EntityId(id))
      .call(Listing.recordPhoto)
      .invoke(request.name)
  }

putStream and Streamed are an endpoint's streamed bodies; HTTP endpoints says what bounds them.

In Python, an endpoint, a consumer, a timed action, an agent and a workflow step reach the client as self.storage, and every call is awaited. A body may be bytes or an async iterable of bytes with its length:

# The service's buckets. `bucket` names one (absent: the unnamed bucket) and `name` the object;
# `op` is get, stat, list (under `prefix`), url or signed-url (`access` read or keep). A post
# keeps its body under `name`; a post with `rules` set to `name` or `prefix` instead tries its
# body as an object's name or a listing's prefix, and keeps nothing.
@post("/storage")
async def keep_object(self, body: str) -> StorageRecord:
    async def call(bucket: Bucket) -> StorageRecord:
        rules = self.request.query_param("rules")
        if rules == "prefix":
            async for _ in bucket.list(body):
                break
        elif rules is not None:
            await bucket.stat(body)
        else:
            content_type = self.request.header("Content-Type") or "application/octet-stream"
            await bucket.put(self._required("name"), body.encode("utf-8"), content_type=content_type)
        return StorageRecord("ok")

    return await self._storage_call(call)

@get("/storage")
async def ask_storage(self) -> StorageRecord:
    async def call(bucket: Bucket) -> StorageRecord:
        name = self.request.query_param("name") or ""
        op = self._required("op")
        if op == "get":
            found = await bucket.get(name)
            if found is None:
                return StorageRecord("absent")
            data = await found.read()
            return StorageRecord("ok", contentType=found.content_type, text=data.decode("utf-8"), length=len(data))
        if op == "stat":
            info = await bucket.stat(name)
            return StorageRecord("absent") if info is None else StorageRecord("ok", contentType=info.content_type, length=info.length)
        if op == "list":
            prefix = self.request.query_param("prefix") or ""
            return StorageRecord("ok", names=[entry.name async for entry in bucket.list(prefix)])
        if op == "url":
            return StorageRecord("ok", url=bucket.url(name))
        if op == "signed-url":
            access: Access = "keep" if self.request.query_param("access") == "keep" else "read"
            return StorageRecord("ok", url=bucket.signed_url(name, access))
        return StorageRecord("refused", "BAD_REQUEST", f"no storage op '{op}'")

    return await self._storage_call(call)

@delete("/storage")
async def delete_object(self) -> StorageRecord:
    async def call(bucket: Bucket) -> StorageRecord:
        await bucket.delete(self._required("name"))
        return StorageRecord("ok")

    return await self._storage_call(call)

In TypeScript the same components have storage, and a body may be a Uint8Array or a ReadableStream<Uint8Array> with its length:

// The service's buckets. `bucket` names one (absent: the unnamed bucket) and `name` the object; `op`
// is get, stat, list (under `prefix`), url or signed-url (`access` read or keep). A post keeps its body
// under `name`; a post with `rules` set to `name` or `prefix` instead tries its body as an object's
// name or a listing's prefix, and keeps nothing.
keepObject: post("/storage", s.string, StorageRecord, (ep: ConformanceEndpoint, req, body) =>
  storageCall(ep, async (bucket) => {
    const rule = req.query.get("rules")
    if (rule === "prefix") {
      for await (const _ of bucket.list(body)) break
    } else if (rule !== null) {
      await bucket.stat(body)
    } else {
      await bucket.put(req.query.get("name") ?? "", new TextEncoder().encode(body), {
        contentType: req.headers.get("content-type") ?? "application/octet-stream",
      })
    }
    return storageRecord("ok")
  }),
),
readObject: get("/storage", StorageRecord, (ep: ConformanceEndpoint, req) =>
  storageCall(ep, async (bucket) => {
    const name = req.query.get("name") ?? ""
    const op = req.query.get("op") ?? ""
    switch (op) {
      case "get": {
        const object = await bucket.get(name)
        if (!object) return storageRecord("absent")
        const bytes = await object.bytes()
        return storageRecord("ok", { contentType: object.contentType, text: new TextDecoder().decode(bytes), length: BigInt(bytes.length) })
      }
      case "stat": {
        const info = await bucket.stat(name)
        return info ? storageRecord("ok", { contentType: info.contentType, length: BigInt(info.length) }) : storageRecord("absent")
      }
      case "list": {
        const names: string[] = []
        for await (const entry of bucket.list(req.query.get("prefix") ?? "")) names.push(entry.name)
        return storageRecord("ok", { names })
      }
      case "url":
        return storageRecord("ok", { url: bucket.url(name) })
      case "signed-url":
        return storageRecord("ok", { url: bucket.signedUrl(name, req.query.get("access") === "keep" ? "keep" : "read") })
      default:
        return storageRecord("refused", { code: ErrorCode.BadRequest, message: `no storage op '${op}'` })
    }
  }),
),
removeObject: del("/storage", StorageRecord, (ep: ConformanceEndpoint, req) =>
  storageCall(ep, async (bucket) => {
    await bucket.delete(req.query.get("name") ?? "")
    return storageRecord("ok")
  }),
),

A Python or TypeScript service speaks to its store itself, from the same variables, so an object never passes through the platform's own program on its way.

In Rust, ctx.storage() answers the client, or None in an entity, a view and a workflow's command handler. A module's put and get carry a whole object of at most 4,000,000 bytes; anything larger goes through a signed URL:

// The service's buckets. `bucket` names one (absent: the unnamed bucket) and `name` the object;
// `op` is get, stat, list (under `prefix`), url or signed-url (`access` read or keep). A post
// keeps its body under `name`; a post with `rules` set to `name` or `prefix` instead tries its
// body as an object's name or a listing's prefix, and keeps nothing. Every answer is a record,
// refusals included.
fn bucket(request: &Request) -> Result<ankka::Bucket, StorageError> {
    let storage = request
        .context()
        .storage()
        .ok_or_else(|| StorageError::Internal("an endpoint has buckets".into()))?;
    Ok(match request.query("bucket") {
        Some(name) => storage.bucket(name),
        None => storage.default(),
    })
}

fn keep_object(request: &Request, body: String) -> Result<StorageRecord, HttpProblem> {
    let kept = Self::bucket(request).and_then(|bucket| match request.query("rules") {
        Some("prefix") => bucket.list(&body).map(|_| ()),
        Some(_) => bucket.stat(&body).map(|_| ()),
        None => {
            let content_type = match request.content_type() {
                "" => "application/octet-stream",
                given => given,
            };
            let name = request.query("name").unwrap_or_default();
            bucket.put(name, body.as_bytes(), content_type)
        }
    });
    Ok(StorageRecord::of(kept.map(|()| StorageRecord::ok())))
}

fn read_object(request: &Request) -> Result<StorageRecord, HttpProblem> {
    let name = request.query("name").unwrap_or_default();
    let answered = Self::bucket(request).and_then(|bucket| {
        match request.query("op").unwrap_or_default() {
            "get" => bucket.get(name).map(|found| match found {
                None => StorageRecord::absent(),
                Some(o) => StorageRecord {
                    content_type: o.content_type,
                    text: String::from_utf8_lossy(&o.body).into_owned(),
                    length: o.length,
                    ..StorageRecord::ok()
                },
            }),
            "stat" => bucket.stat(name).map(|found| match found {
                None => StorageRecord::absent(),
                Some(i) => StorageRecord {
                    content_type: i.content_type,
                    length: i.length,
                    ..StorageRecord::ok()
                },
            }),
            "list" => bucket
                .list(request.query("prefix").unwrap_or_default())
                .map(|entries| StorageRecord {
                    names: entries.into_iter().map(|e| e.name).collect(),
                    ..StorageRecord::ok()
                }),
            "url" => bucket.url(name).map(StorageRecord::url),
            "signed-url" => {
                let access = match request.query("access") {
                    Some("keep") => SignedAccess::Keep,
                    _ => SignedAccess::Read,
                };
                let span = Duration::of_seconds(ankka::storage::DEFAULT_SIGNED_URL_SECONDS);
                bucket
                    .signed_url(name, access, span)
                    .map(StorageRecord::url)
            }
            other => Err(StorageError::BadRequest(format!("no storage op '{other}'"))),
        }
    });
    Ok(StorageRecord::of(answered))
}

fn remove_object(request: &Request) -> Result<StorageRecord, HttpProblem> {
    let removed = Self::bucket(request)
        .and_then(|bucket| bucket.delete(request.query("name").unwrap_or_default()));
    Ok(StorageRecord::of(removed.map(|()| StorageRecord::ok())))
}

In Go, ankka.StorageFrom(ctx) answers the client, or nil for an entity's, a view's or a workflow command's context. A Go service, like a Python or TypeScript one, speaks to its store itself, and a put reads its body from an io.Reader of a declared length:

// storageCall runs f on the bucket the request names (absent: the unnamed bucket), and answers
// every refusal as a record rather than a status.
func storageCall(ctx context.Context, req *ankka.Request, f func(ankka.Bucket) (StorageRecord, error)) (StorageRecord, error) {
    refused := func(err error) (StorageRecord, error) {
        if r, ok := ankka.AsRefusal(err); ok {
            out := storageRecord("refused")
            out.Code, out.Message = r.Code.String(), r.Message
            return out, nil
        }
        return StorageRecord{}, err
    }
    store := ankka.StorageFrom(ctx)
    if store == nil {
        return refused(ankka.Refuse(ankka.Unavailable, "this service has no object storage"))
    }
    var bucket ankka.Bucket
    var err error
    if name := req.Query.Get("bucket"); req.Query.Has("bucket") {
        bucket, err = store.Bucket(name)
    } else {
        bucket, err = store.Default()
    }
    if err != nil {
        return refused(err)
    }
    out, err := f(bucket)
    if err != nil {
        return refused(err)
    }
    return out, nil
}

func storagePut(ctx context.Context, req *ankka.Request, body string) (StorageRecord, error) {
    return storageCall(ctx, req, func(b ankka.Bucket) (StorageRecord, error) {
        switch req.Query.Get("rules") {
        case "":
            contentType := req.ContentType
            if contentType == "" {
                contentType = "application/octet-stream"
            }
            if err := b.Put(ctx, req.Query.Get("name"), strings.NewReader(body), int64(len(body)), contentType); err != nil {
                return StorageRecord{}, err
            }
        case "prefix":
            for _, err := range b.List(ctx, body) {
                if err != nil {
                    return StorageRecord{}, err
                }
                break
            }
        default:
            if _, err := b.Stat(ctx, body); err != nil {
                return StorageRecord{}, err
            }
        }
        return storageRecord("ok"), nil
    })
}

func storageGet(ctx context.Context, req *ankka.Request) (StorageRecord, error) {
    return storageCall(ctx, req, func(b ankka.Bucket) (StorageRecord, error) {
        name, out := req.Query.Get("name"), storageRecord("ok")
        switch op := req.Query.Get("op"); op {
        case "get":
            obj, err := b.Get(ctx, name)
            if err != nil || obj == nil {
                return storageRecord("absent"), err
            }
            data, err := io.ReadAll(obj.Body)
            if err != nil {
                return StorageRecord{}, err
            }
            out.ContentType, out.Text, out.Length = obj.ContentType, string(data), int64(len(data))
        case "stat":
            info, err := b.Stat(ctx, name)
            if err != nil || info == nil {
                return storageRecord("absent"), err
            }
            out.ContentType, out.Length = info.ContentType, info.Length
        case "list":
            for e, err := range b.List(ctx, req.Query.Get("prefix")) {
                if err != nil {
                    return StorageRecord{}, err
                }
                out.Names = append(out.Names, e.Name)
            }
        case "url":
            url, err := b.PublicURL(name)
            if err != nil {
                return StorageRecord{}, err
            }
            out.URL = url
        case "signed-url":
            access := ankka.AccessRead
            if req.Query.Get("access") == "keep" {
                access = ankka.AccessKeep
            }
            url, err := b.SignedURL(name, access, 0)
            if err != nil {
                return StorageRecord{}, err
            }
            out.URL = url
        default:
            out = storageRecord("refused")
            out.Code, out.Message = ankka.BadRequest.String(), fmt.Sprintf("no storage op '%s'", op)
        }
        return out, nil
    })
}
// The service's buckets. bucket names one (absent: the unnamed bucket) and name the object; op
// is get, stat, list (under prefix), url or signed-url (access read or keep). A post keeps its
// body under name; a post with rules set to name or prefix instead tries its body as an
// object's name or a listing's prefix, and keeps nothing.
ankka.Post("/storage", storagePut),
ankka.Get("/storage", storageGet),
ankka.Delete("/storage", func(ctx context.Context, req *ankka.Request) (StorageRecord, error) {
    return storageCall(ctx, req, func(b ankka.Bucket) (StorageRecord, error) {
        return storageRecord("ok"), b.Delete(ctx, req.Query.Get("name"))
    })
}),

A data subject's objects

The platform cannot see what a service writes to a bucket, so an object about a person is the service's to erase. Keep each data subject's objects under that subject's subject prefix, subjects/, the data subject with every / written %2F, and / (subjects/player%2F8c1f/ for player/8c1f). Build it with ObjectRules.subjectPrefix in Scala, subject_prefix in Python, subjectPrefix in TypeScript or ankka.SubjectPrefix in Go; no data subject's prefix holds another's.

erase(subject) on a bucket deletes every object under the prefix, and on a store that keeps versions every version of each, keeps everything else, and answers how many objects it removed and when they are gone for good: at once, or after the store's soft-delete window when ANKKA_S3_SOFT_DELETE_DAYS gives one. Call it from the service's erasure handler, which runs again after each erasure for the re-run window, so an object written under the prefix late is erased too.

Names

An object's name is 1 to 1024 bytes as UTF-8. Its segments are separated by /; it does not begin with /, has no empty segment, no segment . or .. and no control character. Spaces, punctuation and letters outside ASCII are kept as they are. A prefix follows the same rule, except that it may be empty and may end in /. Every SDK refuses the same names for the same reasons, as BadRequest.

There are no directories. listings/42/front.jpg is one name; listing listings/42/ finds it, and nothing exists at listings/42 by itself.

Signed URLs

A signed URL lets a browser upload straight to a signed bucket, or download from it, without the bytes passing through the service. access is read or keep, and the span is fifteen minutes unless you say otherwise, at most seven days. Making one sends no request: the URL is computed from the bucket's address and the service's credential, so it is answered for a name nothing is kept under yet.

A keep through a signed URL is an HTTP PUT of the whole object with its Content-Type. Once it has arrived the object is the bucket's like any other; nothing tells the service, so a browser that uploads tells the service the name it used, and the service records it.

A public bucket's address

url(name) on a public bucket answers the address a browser reads the object at with no signature: the installation's address for the bucket, or a content delivery network's when the installation puts one in front. The service never needs to know which. The address is computed, never fetched, and the object is read from the store directly, never through the service.

On your machine

A service run on your machine with no store's variables keeps its objects in a directory, .ankka/storage under the directory it runs in, or wherever ANKKA_STORAGE_DIR says: one directory per bucket (_default for the unnamed one), holding each object as one file under objects/, its name encoded so that / is %2F (an object a and an object a/b can both be kept, as in a bucket), and its content type, length and the time it was kept in a JSON file of the same name under meta/. The buckets are those the service.json beside it declares. Every project ankka init makes ignores .ankka/ in its .gitignore.

When that service.json declares a bucket, the directory is made at start, and a service that cannot write it does not start, naming the directory and why. A service that declares none (an image run outside a cluster, say, with no service.json beside it) starts wherever it runs; its directory is made at its first call, and a call that cannot be kept there fails as unavailable, naming the directory.

Signed URLs and public addresses work there too. They point at the service's own port, under /_ankka/storage/, which only the runtime may serve: an endpoint declaring a path under /_ankka is refused at start. A signed URL is checked against a key kept in the store's directory, so a URL stays valid across a restart until its span ends. A deployed service serves nothing under /_ankka/storage; there, a URL points at the store.

A service given a store's variables on your machine uses that store. ANKKA_STORAGE_BACKEND chooses outright: s3, directory or memory.

Testing

AnkkaTestKit gives the service a store in memory of its own: no object store and no Docker, and a second kit sees nothing the first kept. It holds the buckets the service.json beside the test declares; a test declares one with declareBucket, and reads and seeds the store through kit.storage:

kit.declareBucket("photos", BucketExposure.Public)
kit.storage.bucket("photos").putBytes("a/b.png", "png".getBytes(UTF_8), "image/png")
assertEquals(
  kit.service.storage.bucket("photos").stat("a/b.png").map(_.contentType),
  Some("image/png")
)
assert(kit.storage.bucket("photos").url("a/b.png").toString.endsWith("/a/b.png"))

What the service kept survives restartService() and is gone when the kit stops. A test against a real store starts the kit with StorageBackendChoice.FromEnvironment and the store's variables. The Python, TypeScript and Rust kits hold the same store in memory, as kit.storage. A Go unit test hands a handler ankka.NewMemoryStorage() through ankka.WithStorage(ctx, store), and a Go integration test chooses the store with ANKKA_STORAGE_BACKEND.

When it fails

Every call refuses with a CommandError, and nothing is kept by a refused put:

Code When
BadRequest a name, prefix, length or span breaks a rule; a stream that ends before its declared length; signedUrl on a bucket that is not signed; url on a bucket that is not public; a workflow's command handler calling
Unavailable the store cannot be reached, refuses or does not answer in time; the service has no unnamed bucket, or no bucket of that name; the service has no credential to sign with
Internal the store answered with something the client cannot read

get and stat answer nothing for an object that is not there; they do not refuse.

What it is not

There are no directories, no rename, no copy, no append and no partial writes: an object is kept and read whole, and replaced whole. Nothing but an object's content type is kept beside it. A put declares its length, so a body of unknown length is read into memory first by your code, or uploaded through a signed URL. A gRPC endpoint has no storage client. The whole list is in Limitations.