Job lifecycle
A unit of edge work is an agent_job row. It is born pending, leased to exactly one agent poll, worked while a heartbeat holds the lease, and finally recorded as succeeded or failed — or reclaimed by a reaper if the agent vanishes. This is that state machine, and how a result flows back into a run.
Everything the agent does is driven by rows in the agent_job table. The control plane enqueues a job; the agent leases it via the long-poll (see Long-polling & leasing); the agent runs it and posts a terminal result. Correctness rests on a small, explicit state machine plus a lease-expiry reaper that guarantees no work is silently lost.
The states
The schema pins the legal states and kinds directly with CHECKconstraints, so an illegal transition can't even be written. attempt starts at 0, max_attempts defaults to 3, and the lone index (agent_id, status) is exactly what the lease query filters on.
kind text NOT NULL CHECK (kind IN ('test','discover','preview','extract','load')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','leased','running','succeeded','failed','expired')),
attempt int NOT NULL DEFAULT 0,
max_attempts int NOT NULL DEFAULT 3,
lease_expires_at timestamptz,
result jsonb,
error text
-- run_id / connection_id are nullable FKs, ON DELETE CASCADE.
-- CREATE INDEX agent_job_lease_idx ON agent_job (agent_id, status);| pending | Enqueued, waiting to be leased by the owning agent's next poll. |
|---|---|
| leased | Claimed by one poll; owned exclusively until lease_expires_at (renewed by heartbeat). |
| succeeded | Agent posted a successful result — dispatched to the next stage. |
| failed | Agent posted a failure (or the run was cancelled). Terminal. |
| expired | Reaper found the lease elapsed AND attempts were exhausted (attempt ≥ max_attempts). Terminal. |
running is a legal status in the schema but the current agent path transitions leased straight to a terminal state — a leased job is already being worked, and the lease + heartbeat are what convey liveness. The extra state exists as headroom for finer-grained progress reporting.The five kinds, and which touch a run
Two kinds — extract and load — are tied to a run and drive it forward. The other three are connection-scoped on-demand probes whose caller polls the job directly for the answer.
| extract | Run-scoped. Stage source rows to blob; success advances the run to its load phase. |
|---|---|
| load | Run-scoped. Replay staged blob into the destination; success completes the run. |
| test | Connection-scoped. Reachability probe; result polled by the console. |
| discover | Connection-scoped. Enumerate streams; result registered into the catalog. |
| preview | Connection-scoped. Sample rows; result rendered by the console. |
The payload split follows the scope. Connection-scoped kinds carry their whole self-contained spec (provider + config with ${secret.X} refs, plus stream/limit for preview) inline in the job payload. Run-scoped kinds carry an empty payload and pull a freshly-derived secretless spec via a second GET (/extract-spec, /load-spec), so the instructions reflect the current run → pipeline → connection even if the job queued earlier.
Atomic leasing
Leasing is a single UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED). The inner select grabs the oldest pending job for this agent (FIFO by created_at) and skips any row a concurrent leaser already locked, so the same job is never handed out twice — no advisory locks, no double-dispatch. Claiming it, bumping attempt, and stamping the lease all happen in that one statement.
-- name: LeaseNextJob :one
-- Atomically claim the oldest pending job for this agent. FOR UPDATE SKIP LOCKED
-- lets concurrent leasers each grab a distinct row without blocking: the inner
-- SELECT locks one pending row and skips rows already locked by a peer, so the
-- same job is never handed out twice.
UPDATE agent_job SET
status = 'leased',
attempt = attempt + 1,
lease_expires_at = now() + make_interval(secs => sqlc.arg('lease_seconds')::int),
updated_at = now()
WHERE id = (
SELECT j.id FROM agent_job j
WHERE j.agent_id = sqlc.arg('agent_id') AND j.status = 'pending'
ORDER BY j.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;The long-poll handler calls this in a loop: on pgx.ErrNoRows (nothing pending) it sleeps jobPollInterval (1s) and retries until the client-requested wait elapses — capped at jobMaxWait (30s) — then returns 204. The loop honours request-context cancellation, so a client disconnect or server shutdown ends it immediately. Full mechanics live in Long-polling & leasing.
Lease, heartbeat, TTL
A lease is a time-boxed claim. When a poll leases a job the server sets lease_expires_at = now() + 60s (jobLeaseTTL) and bumps attempt. While the agent works a long extract or load it renews that lease with a heartbeat every ~20s — comfortably under the 60s TTL — so a healthy job never looks abandoned. The heartbeat goroutine is spawned alongside the work and cancelled the moment the work returns.
// heartbeatLoop renews a job's lease on a fixed interval until ctx is cancelled.
func heartbeatLoop(ctx context.Context, c *client.Client, jobID string) {
t := time.NewTicker(heartbeatInterval) // 20s, well under the 60s lease TTL
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := c.Heartbeat(ctx, jobID); err != nil && ctx.Err() == nil {
fmt.Fprintf(os.Stderr, "heartbeat job %s: %v\n", jobID, err)
}
}
}
}The heartbeat write is scoped to the owning agent and only effective while status = 'leased', so a late heartbeat can never revive a job that was already reaped or completed. The endpoint always answers 204 — a no-op renewal is harmless.
-- name: HeartbeatJob :exec
UPDATE agent_job SET
lease_expires_at = now() + make_interval(secs => sqlc.arg('lease_seconds')::int),
updated_at = now()
WHERE id = sqlc.arg('id')
AND agent_id = sqlc.arg('agent_id')
AND status = 'leased';The reaper: reclaiming abandoned leases
If an agent crashes mid-job it stops heartbeating and the lease elapses. A background reaper periodically sweeps for jobs still leased past their lease_expires_at and either returns them to pending (if attempts remain) or marks them terminal expired once attempt ≥ max_attempts. Work is retried, not lost — and a permanently broken job can't loop forever.
attempt is incremented by the lease, not by completion. So a job is leased at most max_attempts (3) times: lease #1 sets attempt=1, and if that lease expires the reaper sees 1 < 3 → back to pending. After lease #3 (attempt=3) expires, 3 ≥ 3 → terminal expired.-- name: RequeueExpiredJobs :many
-- Lease reaper: reclaim jobs whose lease elapsed while still 'leased'. Jobs with
-- attempts to spare go back to 'pending'; those that exhausted max_attempts become
-- terminal 'expired'. Returns the affected rows.
UPDATE agent_job SET
status = CASE WHEN attempt >= max_attempts THEN 'expired' ELSE 'pending' END,
lease_expires_at = NULL,
updated_at = now()
WHERE status = 'leased' AND lease_expires_at < now()
RETURNING *;func (s *Server) StartAgentJobReaper(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
rows, err := s.q.RequeueExpiredJobs(ctx)
if err != nil {
s.log.Error("agent-job reaper failed", "err", err)
continue
}
if len(rows) > 0 {
s.log.Info("reaped expired agent-job leases", "count", len(rows))
}
}
}
}Posting a result
When a job finishes the agent POSTs /agent/v1/jobs/{id}/result with { status, result?, error? }. Reporting is best-effort from the agent's side — an error is logged but the run loop continues to the next poll. A failed probe (e.g. an unreachable connection in a test) is still a succeeded job carrying a negative result; only an actual execution error fails the job.
spec, err := c.ExtractSpec(ctx, job.ID) // fetch secretless extract-spec
// ...
hbCtx, stopHB := context.WithCancel(ctx)
go heartbeatLoop(hbCtx, c, job.ID) // renew the lease while copying
res, err := exec.Extract(ctx, spec, cfg.Secrets)
stopHB() // stop the heartbeat goroutine
if err != nil {
reportFailure(ctx, c, job.ID, err.Error()) // POST result status=failed
return
}
c.Result(ctx, job.ID, "succeeded",
map[string]any{"blobPrefix": res.BlobPrefix, "rows": res.Rows}, "")Server-side, agentJobResult validates status is exactly succeeded or failed (else 400), gates on ownership via GetAgentJob (a job not owned by the posting agent 404s), records the terminal row idempotently via CompleteAgentJob, then dispatches: success fires onAgentJobResult, failure fires onAgentJobFailed.
s.q.CompleteAgentJob(ctx, sqlc.CompleteAgentJobParams{
ID: id, AgentID: agentID, Status: req.Status, Result: resultBytes, Error: req.Error,
})
job.Status = req.Status
job.Result = resultBytes
if req.Status == "succeeded" {
s.onAgentJobResult(ctx, job) // extract → dispatch load; load → complete run; discover → catalog
} else {
s.onAgentJobFailed(ctx, job, req.Error) // extract/load → fail the parent run
}CompleteAgentJob is idempotent by construction — a repeat call re-writes the same terminal row — and agent-scoped, so no other agent can complete this job. That matters because a result POST can be retried after a flaky network.
Run coupling: how a result advances a run
The dispatch on success is what makes an edge run flow. An extract success records the staged artifact, flips the run to its load phase, sets the run total, and dispatches the load — to the cloud worker if the destination is cloud-placed, or to the destination's own agent if it's edge-placed. A load success completes the run. Every step first checks runIsActive (pending or running), so a late result arriving after a cancel is quietly ignored rather than resurrecting a dead run.
func (s *Server) onAgentJobResult(ctx context.Context, job sqlc.AgentJob) {
if job.Status != "succeeded" {
return
}
// Never resurrect a run that was cancelled or already finished.
if job.RunID.Valid && (job.Kind == "extract" || job.Kind == "load") &&
!s.runIsActive(ctx, uuid.UUID(job.RunID.Bytes)) {
return
}
switch job.Kind {
case "extract":
s.onAgentExtractResult(ctx, job) // record artifact, phase=load, dispatch load
case "load":
s.onAgentLoadResult(ctx, job) // MarkRunSucceeded with row counts
case "discover":
s.onAgentDiscoverResult(ctx, job) // register streams into the catalog
}
}The extract → load hand-off is where placement is resolved. The staged blob lands under a prefix that must byte-match the cloud worker's layout (ws/{ws}/pl/{pipeline}/run/{run}/) so a cloud load reads back exactly where the agent staged. Then the destination's placement decides who loads:
// ... CreateRunArtifact (upsert on run_id) + SetRunPhase("load") + SetRunTotal(rows)
load, dstID, ok := s.runLoadPlacement(ctx, job.WorkspaceID, runID)
if !ok {
return
}
if !load.Cloud {
// Dest is agent-placed: hand off to that agent's load job.
s.q.CreateAgentJob(ctx, sqlc.CreateAgentJobParams{
AgentID: load.AgentID, WorkspaceID: job.WorkspaceID, Kind: "load",
RunID: pgtype.UUID{Bytes: runID, Valid: true},
ConnectionID: pgtype.UUID{Bytes: dstID, Valid: true},
Payload: []byte("{}"),
})
s.runEvent(ctx, runID, "info", "", "Loading staged data on the destination runtime")
return
}
// Dest is cloud: the worker loads from the agent-staged blob.
s.enq.EnqueueMigrate(ctx, runID)
s.runEvent(ctx, runID, "info", "", "Loading staged data on Setu cloud")CancelRunAgentJobs flips pending/leased → failed with error = 'cancelled' and returns the count (a nonzero count tells the caller the work was on an agent). Any result that lands afterward hits the runIsActive guard and is dropped.-- name: CancelRunAgentJobs :execrows
UPDATE agent_job SET status = 'failed', error = 'cancelled', updated_at = now()
WHERE run_id = $1 AND status IN ('pending', 'leased');Run events: narrating an edge run
Agent-executed runs bypass the worker's event stream, so the apiserver emits run events itself at each transition — otherwise the run monitor would show nothing. Emission is best-effort (a failed append is logged, never fatal).
// runEvent appends one row to a run's activity log (best-effort). Agent-executed
// runs bypass the worker's event stream, so the apiserver emits these itself at
// each dispatch/result transition — otherwise the run monitor shows nothing.
func (s *Server) runEvent(ctx context.Context, runID uuid.UUID, level, stream, msg string) {
if _, err := s.q.AppendRunEvent(ctx, sqlc.AppendRunEventParams{
RunID: runID, Level: level, Stream: stream, Message: msg,
}); err != nil {
s.log.Warn("append run event failed", "run", runID, "err", err)
}
}Across one edge run you'll see, in order:
info | "Extracted N rows on the runtime; staged for load" — after onAgentExtractResult records the artifact. |
|---|---|
info | "Loading staged data on the destination runtime" OR "... on Setu cloud" — at the load dispatch fork. |
info | "Loaded N rows on the runtime (W written, Q quarantined)" — after onAgentLoadResult marks the run succeeded. |
error | "runtime extract|load: <message>" — from onAgentJobFailed when an extract/load fails the run. |
A full run, end to end
Tying it together for an edge-source → cloud-destination run: the agent leases the extract, fetches its spec, stages to blob, and reports; the apiserver records the artifact and enqueues the cloud load.
The whole lifecycle in one breath
CreateAgentJob inserts pending; a poll atomically leases it (FOR UPDATE SKIP LOCKED, attempt++, 60s TTL); the agent heartbeats every 20s while working; it posts succeeded/failed; success dispatches the next run stage (by destination placement), failure fails the run; a crashed agent's lease is reaped back to pending — or to expired after 3 attempts. No lost work, no double-processing, no zombie runs.