Advanced: object-store ETL with move-after-read (Ruby)
A bucket trigger polls, and it has no memory of what it has already seen. The only thing that stops an object being delivered again on the next poll is that it is no longer there.
That single fact is what this guide is about. Get it wrong and you build an infinite loop that bills you for every lap.
Source: examples/functions/objectstore-etl-ruby.
What you need
- A project,
platformctl login, and an S3-compatible bucket you control. The platform does not host the store. - About 25 minutes.
after_read has no default, on purpose
--after-read move | delete | none
Omit it and the trigger 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
That refusal is doing you a favour. The three choices are:
| Value | What happens | When it is right |
|---|---|---|
move | The object is relocated once your handler succeeds | You want an audit trail of what was processed. |
delete | The object is destroyed | The data is already durable elsewhere. |
none | It stays, and fires again on every poll | Only when something else clears the bucket. |
--after-read move needs a destination the trigger is not watching:
after_read: move needs move_to.bucket or move_to.prefix -
moving an object onto itself re-delivers it on every poll
Moving to a prefix inside the watched path is the same infinite loop, arrived at by a route that looks like you did the careful thing. Either move to a different bucket, or watch a prefix and move outside it:
--prefix incoming/ --move-to-prefix processed/
Consume the whole object, or fail
The platform moves an object away only when your handler succeeded. That turns partial success into data loss: publish some rows, then raise, and the object is left in place — but publish some rows and then return, and the object is moved away with half its rows never delivered.
So the handler parses and validates everything before publishing anything:
# PARSE AND VALIDATE THE WHOLE FILE FIRST.
rows = []
CSV.parse(text, headers: true).each_with_index do |row, i|
rows << normalise_row(row, i + 2) # +2: one for the header, one for 1-based
end
rows.each_slice(BATCH) do |slice|
publish(slice.map { |r| { 'text' => JSON.generate(r), 'key' => r['id'] } })
end
A handler that published as it parsed would pass every happy-path test and still lose half a file the first time a row was malformed.
An empty object must succeed
if text.nil? || text.strip.empty?
return { 'statusCode' => 200, 'body' => { 'rows' => 0, 'note' => 'empty object' } }
end
Raising on an empty file feels tidy and is a trap: the platform will not retire an object whose handler failed, so an empty file is redelivered on every poll, forever.
Ruby-specific traps
The status key is a string
{ 'statusCode' => 200, ... } # correct
{ statusCode: 200, ... } # silently wrong
The shim looks for the string 'statusCode'. A symbol key is serialised
into the body as an ordinary field and the response defaults to 200 — which
looks fine until the day you need a different status.
to_i and to_f never fail
begin
cents = (Float(amount) * 100).round
rescue ArgumentError
raise MalformedObject, "row #{number}: amount #{amount.inspect} is not a number"
end
"N/A".to_f is 0.0. It does not raise, it does not warn — it turns junk into
a zero-value order that nobody notices for a month. Float() raises, which is
what you want at the edge of a system.
Read both body shapes
return event['data'] if event['data'].is_a?(String)
raw = event['data_base64']
A non-JSON body arrives as text in data, or base64 in data_base64 when the
bytes are not valid UTF-8. Handling only data works until somebody uploads a
file with a stray byte in it.
The row number belongs in the message
"invalid amount" in a 4,000-row file is not actionable. "row 3127: amount "N/A" is not a number" is a fix.
Deploy it
printf '%s' "$SA_KEY" | platformctl secrets put pipeline-key
platformctl pubsub topics create etl-rows
platformctl functions deploy ./examples/functions/objectstore-etl-ruby --name etl
platformctl secrets bindings set etl PIPELINE_KEY --secret pipeline-key
platformctl secrets bindings apply etl
platformctl serverless triggers create etl-incoming \
--type objectstore \
--target etl \
--bucket my-drops \
--endpoint https://s3.us-east-1.amazonaws.com \
--credentials-secret drops-bucket-creds \
--prefix incoming/ \
--after-read move \
--move-to-prefix processed/
Note --prefix incoming/ with --move-to-prefix processed/: the trigger watches
one prefix and retires into another, so a processed object is out of the watched
path.
Dependencies, if you add any, go in a Gemfile beside handler.rb; Bundler
installs them at build time. This example uses only the standard library.
Run it
printf 'id,email,amount\na1,Ada@Example.COM,10.50\nb2,bob@example.com,3\n' > drop.csv
# upload drop.csv to s3://my-drops/incoming/
Within a minute the object appears under processed/ and two messages are on
the topic:
{"id":"a1","email":"ada@example.com","amount_cents":1050}
{"id":"b2","email":"bob@example.com","amount_cents":300}
Note what normalisation did: the email is lowercased and the amount is integer cents, not a float. Money in a float is a rounding bug waiting for a large enough number.
The numbers to compare against
| What | Number |
|---|---|
| Poll interval — time from upload to first byte read | up to 60 s |
| 250-row file, warm | 80-200 ms |
| Rows per publish call | 100 |
| Cold start | 1-2 s |
Batching at 100 is a deliberate middle: one call per row is a round trip per row, and one call for the whole file eventually exceeds the request body limit.
The trigger polls. If you upload and check immediately you will conclude it is broken. Wait a minute.
Testing it without a bucket
Post the CSV straight at the function rather than dropping a file, so you can iterate in seconds instead of a minute:
curl -sX POST "$FUNCTION_URL" -H 'Content-Type: application/json' \
-d '{"data":"id,email,amount\na1,Ada@Example.COM,10.50\n"}'
platformctl invokeThat command posts the agent body shape, {"message": "..."}, so this
handler would see no data at all, find an empty object, and correctly report
zero rows — a pass that proves nothing. Functions take their body over HTTP.
Traps, at the point you hit them
| Trap | What you see | Why |
|---|---|---|
--after-read omitted | The trigger is refused at create | No default, on purpose. |
move into the watched prefix | The same object processed forever | The next poll finds it again. Move outside the watched path. |
none chosen by accident | Same, plus a growing bill | none is only right when something else clears the bucket. |
| Publishing as you parse | A malformed row loses the rest of the file | The object is moved away as consumed. Validate everything first. |
| Raising on an empty file | One empty object redelivered forever | The platform retires an object only on success. |
:statusCode symbol | A different status is ignored | The shim reads the string key. |
.to_f on a money column | Zero-value rows nobody notices | to_f does not raise. Use Float(). |
Only reading data | Works, then breaks on one file | Non-UTF-8 bytes arrive as data_base64. |
| Upload, check immediately | "Nothing happened" | Polling; allow 60 s. |
Teardown
Trigger first — it references the function:
platformctl serverless triggers delete etl-incoming
platformctl delete etl
platformctl pubsub topics delete etl-rows
What it costs to leave running. The function scales to zero and costs no
compute while idle. The trigger polls continuously, which is small but not free.
The Pub/Sub topic reserves its max_bytes from the project budget the moment it
exists, used or not — and consuming messages does not give budget back, so
delete the topic or lower its max_bytes. Objects accumulating under
processed/ cost whatever your object store charges; the platform does not
manage them.
Where next
- Document ingestion pipeline (Python) — the same trigger with
after_read: none, and why. - Ruby functions — the full runtime contract.
- Triggers — every field on every trigger type.