Skip to main content

Functions + Object Storage integration

This integration uses object storage as the intake point and a Function as the processor. A trigger lists the bucket on an interval and POSTs each new object to the function, so you can validate, transform, or index a file without hand-wiring a worker loop.

Two measured facts decide the shape of everything below, so they come first:

  • The object's content is the request body. Your handler is handed the bytes, not a JSON description of them. It needs no S3 client and never talks to the bucket.
  • The object's key is not delivered. Nothing in the request says which file this was — see The object's key is not delivered before you write a filename check.

Use it when​

  • files arrive in a bucket,
  • each object can be processed independently, and is smaller than 8 MiB,
  • up to a minute of latency is fine — the trigger polls, it does not listen.

The pieces​

  • Bucket — the source of new files, in any store that speaks S3. The platform does not host it, so the trigger always needs an endpoint.
  • Trigger — lists the bucket every poll_seconds (default 60), POSTs what it finds, and retires each object according to after_read.
  • Function — receives the object's bytes and does the work.

Step 1: Deploy the processor​

def handle(event: dict) -> dict:
text = event.get("data") # the object's own bytes, decoded as UTF-8
if text is None: # not UTF-8 text: event["data_base64"] holds it
print("skipped: object is not text")
return {"statusCode": 200}
attrs = event.get("_cloudevent", {}) # id, source, specversion, subject, time, type
title = next((line[2:].strip() for line in text.splitlines()
if line.startswith("# ")), "untitled")
print(f"ingesting {len(text)} characters as {title!r} (ce.id={attrs.get('id')})")
# ... write the result somewhere durable ...
return {"statusCode": 200}
platformctl functions deploy ./doc-ingest --name doc-ingest

Three things about the delivery that change how you write the handler:

  • The shape depends on the object. A JSON object arrives parsed as the event itself. Any other text — markdown, CSV, a log line — arrives as event["data"]. A JSON scalar or array arrives as event["data"]. Bytes that are not valid UTF-8 arrive as event["data_base64"], base64-encoded. None of those is an error.
  • The return value is discarded. Every delivery carries a ce-id header, so the shim treats it as a CloudEvent: it answers 204 and throws the result away. The work has to happen inside handle(), not in what it returns. A handler exception becomes a terminal 400 and the delivery is not retried.
  • Every runtime behaves this way. A body that is not JSON arrives as event["data"], and the CloudEvent attributes arrive as event["_cloudevent"], in Python, Node.js, Go and Ruby alike — so a markdown or CSV drop reaches any of them. (Until D0144 only Python accepted a non-JSON body; the other three answered 400 and the object was gone. Pages that still tell you to write bucket-triggered functions in Python are out of date.)

The object's key is not delivered​

This is the trap that costs an afternoon. The CloudEvent attributes under event["_cloudevent"] were measured carrying id, source, specversion, subject, time and type — and subject is the literal string aws-s3-source, the name of the source, not the name of your object. It has no prefix and no extension, so a suffix check on it:

if not attrs.get("subject", "").endswith(".md"): # rejects every file in the bucket
return {"statusCode": 200}

rejects everything, and a dedupe keyed on it collapses every delivery into one. Nothing errors: the poller keeps retiring objects, the function keeps returning 200, and the work silently never happens.

Print event["_cloudevent"] on your first delivery and read what your store actually sends. When nothing there looks like a key — an object key contains a / or a . — name the object yourself:

  • Derive the name from the content — a markdown # H1, a CSV header row, an id field, as the handler above does. Fall back to a hash of the content so two unnamed objects stay distinct.
  • Or write the identity into the object when you produce it, so the delivery carries it in the body.

Step 2: Connect the trigger​

An object-store trigger needs four fields: bucket, endpoint, credentials_secret_name and after_read. None of them has a default.

platformctl serverless triggers create doc-drop \
--target doc-ingest \
--type objectstore \
--bucket uploads \
--endpoint https://object.us-southcentral1-a.crusoecloudcompute.com \
--region us-southcentral1-a \
--credentials-secret crusoe-object-store \
--prefix incoming/ \
--after-read move \
--move-to-prefix processed/

Four things to get right here:

  • credentials_secret_name is not your Crusoe Cloud API key. Object storage answers that key with 403. On a project connected to Crusoe Cloud the platform has already minted a real S3 key and stored it as the Secret crusoe-object-store (keys aws.accessKey and aws.secretKey) — use that name. If it is missing, open Project Settings (the page that connects a project to Crusoe Cloud) and press Check again — the platform mints the key only for a project that has none.

  • region must match the bucket's. It goes straight to the poller's S3 client, so a mismatch fails at connect time, inside the poller, with nothing on the trigger to say why. The default is us-east-1, which is almost certainly wrong for a Crusoe bucket.

  • after_read is required and has no default, because a trigger that never retires an object fires on it forever. Leaving it out is refused:

    source.objectstore.after_read must be "move", "delete" or "none" - there is no default, because a bucket trigger that never retires an object fires on it forever

    move needs a destination (--move-to-bucket, --move-to-prefix, or both), or it would move the object onto itself and re-deliver it on every poll.

  • retry does not apply to this source. A trigger's retry.max_attempts and retry.backoff reach schedule and Pub/Sub sources only; the bucket poller reads neither, and there is no dead-letter destination anywhere in this path.

Step 3: Test the pipeline​

Upload a small test file under the watched prefix and watch the function's logs.

Wait a full minute before deciding nothing happened. This is a poller, not a notification listener: poll_seconds defaults to 60, so a drop can sit for up to a minute before your function runs. One poll consumes at most max_messages_per_poll objects (default 10), so a bucket that gains 10,000 files does not become 10,000 simultaneous invocations either.

The acceptance signal is not "the upload succeeded"; it is "the function logged the content and produced the downstream result", and — with after_read: move or delete — "the object is no longer sitting in the watched prefix".

Common mistakes​

  • Reading event["bucket"] or event["key"]. Neither field exists. The body is the object itself; see Step 1.
  • Trusting subject as a filename. It is the source's own name, aws-s3-source, on every delivery.
  • Sending objects larger than 8 MiB. The body is the object, so the shim's request cap is a cap on object size: a bigger object is refused with 413 body exceeds 8388608 bytes before handle() runs. Keep the watched prefix to small files, and drop a manifest that names the large object instead.
  • Making the function non-idempotent. With after_read: none the same object is delivered again on every poll, forever, by design — the handler has to be safe to run twice on the same file. The trigger says so too: its No repeat delivery readiness check stays waiting and names the bucket.
  • Expecting a retry budget or a dead-letter path. There is neither on this source. If the work can fail transiently, catch it in the handler and write the object somewhere you can retry from.

Next steps​