Skip to content

Serverless and short-lived jobs

Capture batches in the background. That is what keeps it off your request path, and it is also the one thing that can lose you rows: a process that exits, or a runtime that freezes, takes its queue with it.

Anywhere the platform can freeze or kill you between requests, deliver explicitly.

They are not interchangeable, and picking the wrong one in a serverless handler has a lasting effect.

flush()shutdown()
Delivers queued rowsYesYes
Stops the config poller and background workerNoYes
Capture still works afterwardsYesNo
Use it whenThe process stays alive but may freezeThe process is exiting

flush() takes a timeout and returns whether the queue actually drained. It never raises. Python’s default is 3.0 seconds and TypeScript’s is 3000 milliseconds, and both are arguments you can change.

delivered = metergraph.flush() # default timeout 3.0 seconds
delivered = metergraph.flush(10.0) # wait longer

Python registers shutdown() with atexit when init() succeeds, so a script that runs to completion delivers its rows without you doing anything. Node has no equivalent hook, so in TypeScript call await mg.shutdown() yourself from your normal shutdown path.

TypeScript picks a delivery strategy at startup. Python has one strategy: a daemon background thread.

ModeBehavior
backgroundA timer flushes on an interval, 5000 ms by default. Used for long-running servers
bufferedNo timer. Every enqueued row schedules a flush immediately, and delivery uses keepalive on bodies up to 64 KiB

The default is auto, which chooses buffered when it sees any of:

  • AWS_LAMBDA_FUNCTION_NAME in the environment
  • VERCEL in the environment
  • a navigator.userAgent of Cloudflare-Workers

Otherwise it chooses background. Override it if detection is wrong for your runtime:

mg.init({ transport: "buffered" });

buffered mode helps but is not a guarantee on its own. Scheduling a flush does not mean the runtime will let it finish. That is what wrapHandler and bindWaitUntil are for.

handler.py
import metergraph
def handler(event, context):
result = do_work(event)
metergraph.flush() # default timeout 3.0 seconds
return result

Lambda freezes the execution environment as soon as your handler returns, so the background thread may not run again until the next invocation, if there is one. atexit does not fire between invocations either. Flush before you return.

Hand the platform’s own keep-alive to the SDK, once per request, and delivery continues after your response is sent.

src/worker.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
mg.bindWaitUntil(ctx);
return respond(request);
},
};

bindWaitUntil accepts either an object with a waitUntil(promise) method, such as the Worker ExecutionContext, or a bare waitUntil function. Once bound, every scheduled flush is handed to it instead of being fired and forgotten.

Binding is cheap and idempotent in effect: the last binding wins, so calling it at the top of each request is the intended usage rather than a leak.

VERCEL in the environment selects buffered mode automatically. From there, either wrap the handler or bind the platform’s waitUntil.

app/api/route.ts
import * as mg from "metergraph";
import { waitUntil } from "@vercel/functions";
export const POST = mg.wrapHandler(async (request: Request) => {
return respond(request);
});
Alternative: bind waitUntil
mg.bindWaitUntil(waitUntil);

Use one or the other. Both together is harmless but pointless.

Nothing extra. The background timer delivers on its own interval. Call shutdown() from your existing termination handler so the last few rows are not lost on deploy.

# atexit already calls shutdown() for you. Call it explicitly only if
# your process exits by a path atexit does not cover.
metergraph.shutdown()

shutdown() delivers queued rows and stops the background work, so a separate flush() is not needed. Reach for flush() only when you want delivery while Metergraph keeps running.

Python registers a fork handler. A forked child gets a clean writer and its own background thread, and rows the parent had queued stay with the parent. So a pre-forking server such as Gunicorn works, but the queue is not inherited: each worker delivers its own rows and needs its own flush or shutdown.

Rows are dropped rather than blocking your application. That is the deliberate trade. It happens when:

  • the in-memory queue is full (METERGRAPH_QUEUE_SIZE, default 2000)
  • the transport is in backoff after a failure, which doubles from 1 second up to a 60 second ceiling
  • a single row is still too large after the batch has been split down to one row
  • the server rejected that specific batch with a 400, 404, 413 or 422, which drops that batch only

A 401 or 403 is the exception that is not just a drop. With repository identity configured, the session token is invalidated and a new one is fetched. Without it, capture is disabled for the rest of the process and the SDK says so: Metergraph authentication failed; capture disabled for this process.

Drops are counted and reported in the meta block of the next successful batch, so the loss is visible rather than silent. If you see drops in a serverless runtime, the usual cause is a flush that timed out. Give it a longer timeout, and check that flush() returned true.