- Posts
- Your Function Outlived Its Host
Your Function Outlived Its Host
You wrote an eval job. It scores 10,000 rows against a model, one call each. It takes about forty minutes.
Your platform kills it at fifteen. Or at sixty seconds, if it’s a Vercel Pro function. That isn’t a bug — an HTTP request started your job, and your job outlived the request.
What Ironflow does next
Section titled “What Ironflow does next”Here’s the part people expect to go badly, and it doesn’t. Ironflow notices the dead connection, marks the run WAITING, and schedules a retry — the same path any HTTP failure takes, because from the engine’s side a platform kill and a crashed endpoint look identical.
On the next attempt, every step that already finished replays from the record instead of running again. Your 3,700 scored rows are not re-scored. You don’t pay the model twice. You get run.updated when it reschedules and run.failed if it ultimately gives up, so nothing happens silently.
So the work is safe. The clock still isn’t.
The clock was never yours
Section titled “The clock was never yours”Every attempt gets the same fifteen minutes, because the limit belongs to your host, not to your run. And replay isn’t free — attempt two spends part of its window catching up on attempt one’s work before it can do anything new. Attempt three spends more. The productive slice shrinks each time, and after three attempts (the default maxAttempts) the run fails with the job unfinished.
That’s the trap: durability inside the handler cannot buy you time outside it.
So you do what everyone does. You chunk it. Ten thousand rows becomes two hundred batches of fifty, each its own invocation, each safely inside the window. Now you need a queue, a cursor, a fan-in step to know when all two hundred landed, and a story for the batch that failed while its neighbours succeeded.
You didn’t want a distributed system. You wanted a for-loop.
Give the loop somewhere to run
Section titled “Give the loop somewhere to run”Pull mode inverts the direction. Your worker dials out to Ironflow and asks for work. There’s no inbound request, so there’s no inbound timeout, and nothing between Ironflow and your code holding a stopwatch.
The function doesn’t change. Here is the TypeScript version:
TypeScript
Section titled “TypeScript”import { createFunction } from "@ironflow/node";
export const scoreEvalSet = createFunction( { id: "score-eval-set", triggers: [{ event: "eval.batch.requested" }], mode: "pull", }, async ({ event, step }) => { const { setId } = event.data as { setId: string }; const rows = await step.run("load-rows", () => loadRows(setId));
const scores: number[] = []; for (const [i, row] of rows.entries()) { scores.push(await step.run(`score-${i}`, () => scoreRow(row))); }
return step.run("save-scores", () => saveScores(setId, scores)); },);Under push, that function is hosted by a route handler on someone else’s clock:
import { serve } from "@ironflow/node";
export const POST = serve({ functions: [scoreEvalSet], signingKey: process.env.IRONFLOW_SIGNING_KEY,});Under pull, it’s hosted by a process you own:
import { createWorker } from "@ironflow/node";
const worker = createWorker({ serverUrl: process.env.IRONFLOW_SERVER_URL, functions: [scoreEvalSet], maxConcurrentJobs: 4,});
await worker.start();process.on("SIGTERM", () => worker.drain());The same function and hosting choice are available in Go:
type EvalBatch struct { SetID string `json:"setId"`}
var ScoreEvalSet = ironflow.CreateFunction( ironflow.FunctionConfig{ ID: "score-eval-set", Triggers: []ironflow.Trigger{{Event: "eval.batch.requested"}}, Mode: ironflow.PullMode, // use PushMode with Serve below }, func(ctx ironflow.Context) (any, error) { var batch EvalBatch if err := ctx.Event.Data(&batch); err != nil { return nil, err }
rows, err := ironflow.Run(ctx, "load-rows", func() ([]Row, error) { return loadRows(batch.SetID) }) if err != nil { return nil, err }
scores := make([]Score, 0, len(rows)) for i, row := range rows { score, err := ironflow.Run(ctx, fmt.Sprintf("score-%d", i), func() (Score, error) { return scoreRow(row) }) if err != nil { return nil, err } scores = append(scores, score) }
return ironflow.Run(ctx, "save-scores", func() (any, error) { return saveScores(batch.SetID, scores) }) },)With Mode: ironflow.PushMode, host it behind an HTTP route:
handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{ScoreEvalSet}, SigningKey: os.Getenv("IRONFLOW_SIGNING_KEY"),})
http.Handle("/api/ironflow", handler)log.Fatal(http.ListenAndServe(":3000", nil))With Mode: ironflow.PullMode, run it in a worker:
worker := ironflow.NewWorker(ironflow.WorkerConfig{ ServerURL: os.Getenv("IRONFLOW_SERVER_URL"), Functions: []ironflow.Function{ScoreEvalSet}, MaxConcurrentJobs: 4,})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)defer stop()go func() { <-ctx.Done() worker.Drain()}()
log.Fatal(worker.Run(ctx))The handler body is identical. One field on the config changed (mode: "pull") and the host changed. The chunking, the queue, the cursor, the fan-in — none of it ever gets written.
Two things come along for free once the worker owns the process: it dials out, so it can sit inside your VPC with no public endpoint, and it stays warm, so a preloaded model or a connection pool survives between jobs instead of cold-starting every time.
When push is still the right answer
Section titled “When push is still the right answer”A worker is a process, and a process is yours to babysit. You deploy it, watch it, restart it, and pay for it while it idles. Push mode hands all of that to your platform, and for work that fits comfortably inside a request that’s a genuinely better trade — scale-to-zero, no capacity planning, nothing to page you about at 3am.
The line isn’t “long jobs are better.” It’s: if the work fits inside the request, let the request hold it. If it doesn’t, stop pretending it does.
Try it
Section titled “Try it”brew install sahina/tap/ironflowChoose a Tier-1 scaffold.
TypeScript
Section titled “TypeScript”ironflow init my-app && cd my-appironflow serve --devpnpm devironflow init my-app --template go-quickstart && cd my-appironflow serve --devgo run main.goThe execution modes guide covers the rest — worker labels for routing, maxConcurrentJobs tuning, and the ConnectRPC streaming transport if polling latency matters to you.
Your function was never the problem. Its host was.