Batch jobs¶
native.batch hands a unit of work to a partner system through a dedicated bucket, and runs a
continuation when the partner is done. There is no connection to the partner and nothing waits: the
invocation that submits the job ends, and the partner's answer arrives later as an S3 notification.
The batch bucket is separate from the data buckets in buckets.map. Give the partner
access to it and to nothing else.
Layout¶
Everything for a tenant sits under <batch.prefix><tenant>/:
jobs/<job>/request.json written by the script; the partner reads it
jobs/<job>/inputs/<name>.NNNN input files the script attached (optional, any number)
jobs/<job>/<files> result files, written by the partner
jobs/<job>/done.json written by the partner last: the manifest and the commit marker
state/<job>.json written by the script; the partner never needs it
Scripts address keys relative to the tenant folder; the partner uses the full keys.
Script API¶
function on_invoke(ctx, ev)
local job = native.batch.new() -- { id = "..." } makes resubmitting idempotent
job:input_rows("ids", ev.ids) -- optional: large inputs as files, not params
local customers = job:query("customers", "customers_by_id", { input = "ids" })
job:query("orders", "open_orders", { since = ev.since })
job:state({ ref = ev.ref }) -- for the continuation; the partner never sees it
job:continue_with("settle") -- script to run when the partner is done
return job:submit() -- writes state, then request.json; returns the job id
end
BatchJob method |
|
|---|---|
query(name, query, params?) |
names a query for the partner. name (letters, digits, -, _) is how results are found later. At most 64 queries per job |
input(name, content, opts?) |
attaches a file: a string or a stream. Calling it again with the same name adds another part. opts.ext (default .ndjson) is the file extension |
input_rows(name, rows, opts?) |
attaches rows from an array or an iterator function, as NDJSON cut into parts of about 64 MiB. The row count is recorded |
state(value) |
any JSON value kept for the continuation |
continue_with(script) |
the script native.batch.route picks for this job's done.json |
submit() |
writes state/<job>.json, then request.json. A job needs at least one query and can be submitted once |
Input files are written when you attach them, but are inert until request.json exists, because
that is the object the partner reacts to. The queries refer to inputs by name in their params; what
that means is a contract between your scripts and the partner.
The partner's contract¶
request.json:
{ "v": 1, "job": "9f3c…", "created_ms": 1761300000000,
"inputs": { "ids": { "rows": 3, "parts": [ { "key": "inputs/ids.0000.ndjson", "bytes": 27, "sha256": "…" } ] } },
"queries": [ { "name": "customers", "query": "customers_by_id", "params": { "input": "ids" } } ] }
When all queries have been run the partner writes the result files into jobs/<job>/, then
done.json last:
{ "ok": true,
"results": {
"customers": { "rows": 120000, "parts": [
{ "key": "customers.0000.ndjson.gz", "bytes": 9100000, "sha256": "…" },
{ "key": "customers.0001.ndjson.gz" } ] },
"orders": { "parts": [ { "key": "orders.0000.ndjson.gz" } ] } } }
or { "ok": false, "error": "partner timeout" }.
- A result may have any number of parts; they are read in the listed order. Part keys are relative to the job folder and may not climb out of it.
- Results and inputs are NDJSON (one JSON value per line, blank lines skipped), optionally
gzipped; gzip is detected from the data, not the name.
rows,bytesandsha256are optional and, when present, are verified. done.jsonis the commit marker. Writing it last means the continuation never sees a partial result. Every query of the request must have an entry inresults.- The format of a result is not declared: whoever opens it knows what it asked for.
Wiring the continuation¶
Point an S3 notification (s3:ObjectCreated:*, suffix filter done.json) from the batch
bucket at the same queue as your data buckets. Only done.json
should notify, so parts do not each wake a script. The event is routed like any other S3 event, with
bucket = "$batch" and a key relative to the tenant's folder. $batch is not a valid
buckets.map name, so it never collides with a data bucket, and an object in a data bucket named
jobs/x/done.json is not a batch event.
The router asks native.batch.route which script owns the job:
local function on_route(_ctx, event)
local routed = native.batch.route(event) -- nil unless this is a batch done.json
if routed then
return routed.script and { script = routed.script, event = routed.event } or "batch-gone"
end
return "default"
end
routed.event is the original event plus job. routed.script is nil when the job's state is
gone, which is a duplicate delivery after cleanup; send it to a script that does nothing.
The continuation¶
function on_queue(ctx, ev)
local job = native.batch.hydrate(ev) -- nil if not complete or already cleaned up
if not job then return end
local by_id = {}
for row in job:rows("customers") do by_id[row.id] = row end
for row in job:rows("orders") do ... end
return { delete = job:keys() } -- clean up after success
end
hydrate(event, { allow_failure = true }) returns a job with ok = false and error instead of
raising when the partner reported a failure.
BatchResult |
|
|---|---|
id, ok, error, state |
the job id, the partner's verdict, and the value passed to state() |
results |
name -> { rows?, parts } |
rows(name) |
iterator over every part's rows, in order |
open(name, part?) |
a stream of one part (1-based), gunzipped if needed, for non-row data |
parts(name) |
number of parts |
keys() |
every object of the job, for the handler's delete = result |
Reading checks what the manifest promises: a missing part, a size or sha256 that differs, bad JSON
(the error names the line) and a row count different from rows all raise. Integrity is verified when
a part is read to its end. Treat the data as untrusted input regardless.
delete = job:keys() runs only after the handler succeeds, so a failed continuation is retried with
the job intact. The engine deletes at most 100 objects per result; for jobs with more files, expire the
jobs/ and state/ prefixes with a bucket lifecycle rule.
Several stages¶
A continuation may submit another job instead of finishing: copy what it needs into the next
state(), continue_with the next script, and delete the old job's keys. Use a second stage only
when a later query needs an earlier result; independent queries belong in one job.
Slow HTTP requests: job:http¶
A request that may take longer than the invocation can wait is submitted as a job. The invocation ends, the host performs the request in the background, and the continuation runs when the response is in.
function on_invoke(ctx, ev)
local job = native.batch.new()
job:http("charge", { -- same options as native.http.request
method = "POST",
url = native.config.api_base .. "charges",
headers = { authorization = "Bearer {{vault:stripe.key}}" },
body = native.json.encode(ev.order),
vault = { "header" },
})
job:state({ order = ev.order.id })
job:continue_with("charged")
return job:submit()
end
function on_queue(ctx, ev) -- script "charged"
local job = native.batch.hydrate(ev, { allow_failure = true })
if not job then return end
if not job.ok then error(job.error) end -- the request could not be completed
local r = job.results.charge -- { status, headers, parts }
local body = native.json.decode(job:body("charge"))
return { delete = job:keys() }
end
- The request is checked when
job:httpis called:http.allow, vaultallowlists and themax_http_requestscount apply exactly as fornative.http, so a request that could never be sent fails in the submitting script. - The request is never written to the bucket, so vault secrets stay in memory. Only the response
is: the body as
jobs/<job>/<name>.0000.body, and status and headers indone.json, which is written last and triggers the continuation like a partner'sdone.json. Vault values are redacted from the response as fornative.http. - A response with any status counts as an answer (
ok = true; checkresults.<name>.status).ok = falsewith anerrormeans no response arrived: connection failure, timeout (15 minutes per request), or the response could not be stored. - A job holds several
job:httpcalls, which run concurrently, and the continuation runs once when all are done. A job cannot also havequery/inputfor a partner. - Throttling applies; a request waits for its slot (up to an hour) in the
background instead of using up a script's time budget. Redirects are not followed. The body is
capped at
max_http_response_mb. - Fargate only. The server performs the request in a thread, which Lambda cannot do because the
sandbox freezes when the invocation returns; there
job:httpraises an error. The request lives only in the process: if the task is stopped (SIGTERM on a deploy or scale-in), its running jobs are failed at once withok = false; if it dies without warning (a crash, OOM), the job never gets adone.json. No retry happens either way. Treat the same way as a partner that never answers (Not covered), and keep the calls idempotent.
Tenant config and deployment¶
Tenant config.json: "batch": true enables native.batch for the tenant (otherwise the script gets an
error, not the bucket). Deployment config:
batch.prefix is empty or ends in /. The bucket may not also be in buckets.map. It needs the same
runtime permissions as a data bucket (GetObject, PutObject, DeleteObject, ListBucket, multipart
upload for large inputs).
Partner IAM is the security boundary, since the partner can write anything it can reach:
| Principal | Access |
|---|---|
| partner | read <prefix>*/jobs/*/request.json and inputs/*; write <prefix>*/jobs/*. Nothing under state/ |
| integraal | the whole bucket |
Keeping state/ out of the partner's reach is what lets the continuation trust it. Scope the
partner per tenant if tenants must not see each other's jobs.
Limits¶
Each bucket call counts against max_data_ops; each part read is one. Streams obey
max_stream_mb and the invocation deadline. Rows longer than max_object_mb are rejected.
native.ndjson.rows(string_or_stream) reads any NDJSON the same way outside of jobs.
Not covered¶
If the partner never writes done.json, nothing runs. Detect that with a periodic script that lists
jobs/ and compares the age of request.json against done.json.