Help us shape the future of Go! Take the 2026 Go Developer Survey

jobrun

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: MIT

README

jobrun

ci Go Reference

A durable job queue on Postgres, in one Go binary: gRPC control plane, in-process worker pool with fenced leases, out-of-process workers over WorkerService (pkg/jobworker, cmd/jobworker), cron scheduler, retries with backoff, a dead-letter state you can retry, exactly-once side effects for in-process handlers on the transaction path, W3C trace context carried from enqueue to run, and a live /admin page. No broker, no cron library, no Prometheus client: SELECT ... FOR UPDATE SKIP LOCKED plus LISTEN/NOTIFY does the dispatching and one Postgres does 1000 jobs/s off a partial index. Design record: docs/PLAN.md (ADR-0001; ADR-0002 for remote workers, fencing tokens, traceparent).

Architecture

 jobrun CLI / any gRPC client
        |  Enqueue / Get / List / Cancel / Retry / Purge / Stats   (JobService)
        |  Create / Update / Delete / Get / List                   (ScheduleService)
        v
 +-- jobrund ---------------------------------------------------------------------------------+
 |  gRPC :50052  -> interceptors (Recover > RequestID > Logging) -> server.JobServer            |
 |                                                                       |                     |
 |        Store.Enqueue (one tx): INSERT jobs ON CONFLICT (queue, idempotency_key)             |
 |                                INSERT job_events, pg_notify('jobrun_ready', queue)          |
 |                                                                       |                     |
 |  Notifier (LISTEN, 1 conn) --wake--> Pool claim loop per queue <--tick-- poll floor 200 ms   |
 |                                       |  Claim: FOR UPDATE SKIP LOCKED, attempts++, lease   |
 |                                       v                                                     |
 |                        bounded handler goroutines (Concurrency) <-- heartbeater extends     |
 |                            |                     |                  leases, fences lost ones |
 |                     ok: Store.Complete (one tx)   err: Store.Fail                             |
 |                         handler Commit + processed_effects        queued@run_at+backoff       |
 |                         + owner-guarded UPDATE + job_events       or dead / cancelled         |
 |                                                                                             |
 |  Scheduler (advisory-lock leader, tick 5 s): due schedules -> Enqueue(key sched:<id>:<ts>)  |
 |  Reaper (tick LEASE_TTL/3): expired leases -> requeue with backoff or dead; retention purge  |
 |  Broker: every transition -> /admin/events (SSE), /admin/stats, /metrics, OTel counters     |
 +---------------------------------------------------------------------------------------------+
        |
   Postgres 17 (:5434): jobs, job_events, schedules, processed_effects

Guarantees

  • At-least-once execution, at-most-max_attempts starts. attempts increments at claim time, so a process that dies mid-job burns exactly one attempt; the reaper requeues the lease.
  • Exactly-once effects for handlers that implement job.TxHandler: the returned Commit runs inside the completion transaction together with a processed_effects row keyed ("job:"+type, idempotency_key|id) and the owner-guarded jobs update. A lost lease rolls the effect back; a Retry of a job whose effect already committed skips the closure.
  • Lease fencing with tokens. Every claim bumps jobs.lease_token; heartbeat, Complete and Fail must present (owner, token), so a stale attempt cannot extend or finish a job even when the same worker reclaimed it after a reap.
  • Lost-lock detection. A lease a heartbeat reports lost - or one that could not be renewed before it may have expired (claim/last good heartbeat send time + TTL - one heartbeat period, monotonic clock) - is fenced: the handler's ctx is cancelled within one heartbeat period and the worker never calls Complete or Fail for it, even if the handler returns success. Counted as jobrun_lease_fenced_total (in-process pool and remote workers alike).
  • Cooperative cancel. Cancel on a queued job is immediate; on a leased job it sets cancel_requested, the next heartbeat cancels the handler and the job ends cancelled.
  • Retry ladder. Exponential backoff with full jitter (1s, x2, cap 5m); a retry waits as queued with a future run_at. job.ErrPermanent skips the ladder. Exhaustion is status='dead', a state not a table, and retry resets it to queued with attempts=0.
  • Idempotency keys are unique per queue: a replay returns the stored job (created: false), a different body is ALREADY_EXISTS. The scheduler stamps sched:<schedule_id>:<next_run>.
  • The clock rule. Every comparison and default (run_at, leases, finished_at, purge age) uses the database clock; jobrund warns at startup when the app clock is off by more than 2 s.

Remote workers

jobrund also serves jobrun.v1.WorkerService: a worker opens one Claim stream (worker id, queues, credits = free slots), receives leased jobs with their fencing token, lease TTL and traceparent, and reports with unary Heartbeat / Complete / Fail. The server never pushes more jobs than credits granted; a returned credit only triggers a claim when the stream was credit-bound. Leases are owned by remote:<worker_id>, so jobrun list tells them apart.

w := jobworker.New(conn, jobworker.Config{Queues: []string{"emails"}, Concurrency: 16})
_ = w.Handle("email.send", jobworker.HandlerFunc(func(ctx context.Context, j jobworker.Job) error {
	return send(ctx, j.ID, j.Payload) // idempotent on j.ID: effects are at-least-once
}))
err := w.Run(ctx) // reconnects with backoff; drains in-flight handlers on ctx.Done()

jobworker -addr localhost:50052 -queues default,bench -concurrency 16 (or task worker -- ...) runs the built-in handlers remotely.

Guarantee: at-least-once effects. A remote worker killed mid-job leaves its lease to expire; the reaper requeues the job and another worker runs it again (the integration test kills a real jobworker process by PID and watches exactly that, ~2.3 s end to end with a 2 s TTL). job.TxHandler exactly-once stays in-process: its Commit must run inside the completion transaction, which a remote process cannot join. Key remote effects on the job id.

Tracing

Enqueue opens jobrun.enqueue under the caller's trace (otelgrpc) and stores its W3C traceparent on the job (Job.traceparent in the API). The run span (jobrun.run, in-process or in pkg/jobworker) is its child, and jobrun.complete is a child of the run span - for remote workers the run span's context comes back on Complete/Fail. Retries are sibling run spans in the same trace. Nothing is stored when the producer is untraced.

Quick start

task db:up                                   # postgres:17 on :5434, healthchecked
task run                                     # migrates, then serves gRPC :50052 + admin 127.0.0.1:9110
task build; .\bin\jobrun.exe health          # in another shell

Trimmed from the smoke run (jobrun get --id ... --events after enqueue --type fail --payload '{"times":2}'):

AT                        FROM         TO         ATTEMPT  ERROR
2026-09-19T11:39:31.762Z  unspecified  queued     0
2026-09-19T11:39:31.770Z  queued       leased     1
2026-09-19T11:39:31.773Z  leased       queued     1        fail: attempt 1 of 2 requested failures
2026-09-19T11:39:32.445Z  queued       leased     2
2026-09-19T11:39:32.449Z  leased       queued     2        fail: attempt 2 of 2 requested failures
2026-09-19T11:39:33.845Z  queued       leased     3
2026-09-19T11:39:33.849Z  leased       succeeded  3

A {"permanent":true} job goes leased -> dead on attempt 1 (permanent failure: fail: permanent requested); jobrun retry --id adds dead -> queued with attempt 0. A */1 * * * * schedule in America/New_York materialised jobs with runAt on the exact minute and idempotencyKey: sched:01a0b978-...:2026-09-19T11:42:00Z, claimed 6 ms after enqueue.

CLI

jobrun [-addr localhost:50052] [-timeout 5s] <command>; single objects print as protojson, lists as tables.

Command Flags
health
enqueue --type [--queue default] [--payload '{}'] [--key] [--priority] [--delay 5s | --run-at RFC3339] [--max-attempts]
get --id [--events]
list [--queue] [--status queued|leased|succeeded|dead|cancelled] [--type] [--size 50] [--token]
cancel, retry --id
purge [--queue] [--status dead,cancelled] [--older-than 24h] [--limit]
stats [--queue] (cached depth by status)
schedule create --name --cron "*/1 * * * *" [--tz UTC] --queue --type [--payload] [--priority] [--max-attempts] [--disabled]
schedule list|get|update|delete --id, update takes [--cron] [--tz] [--enabled true|false] [--type + template flags]
bench [--rate 200] [--duration 60s] [--queue bench] [--type noop] [--direct] [--workers 8] [--wait 60s]

Built-in handlers: noop, sleep ({"ms":N}), fail ({"times":N} or {"permanent":true}), echo. Reflection is on, so grpcurl -plaintext localhost:50052 list works too.

Configuration (internal/config)

Var Default Notes
DATABASE_URL required postgres://jobrun:jobrun@localhost:5434/jobrun?sslmode=disable locally
GRPC_ADDR / ADMIN_ADDR :50052 / 127.0.0.1:9110 ports frozen in docs/PLAN.md
GRPC_TOKEN empty bearer token for every gRPC call except grpc.health.v1.Health (>= 16 chars); the jobrun CLI and jobworker read the same variable. Empty is refused in APP_ENV=prod unless GRPC_ADDR is loopback; elsewhere jobrund warns at startup
ADMIN_ALLOW_REMOTE false the admin surface has no auth: a non-loopback ADMIN_ADDR is refused unless this is true (the Dockerfile sets it)
ADMIN_CORS_ORIGINS empty comma list of exact origins (http://localhost:3000) allowed to read /admin/stats and /admin/events cross-origin; empty sends no CORS header
PGX_MAX_CONNS WORKER_CONCURRENCY + 8 must be >= concurrency + 2
WORKER_QUEUES default comma list; task run uses default,bench
WORKER_CONCURRENCY / WORKER_BATCH 16 / 32 handler goroutines / jobs per claim
CLAIM_FIFO_EVERY 10 every n-th claim per queue takes the oldest ready jobs regardless of priority (anti-starvation; index jobs_ready_fifo); 0 = strict priority
LEASE_TTL / HEARTBEAT_EVERY 30s / 10s heartbeat must be < TTL/2; reaper ticks TTL/3
JOB_TIMEOUT 10m per handler, also the pool's own drain budget
POLL_FLOOR 200ms claim tick when NOTIFY is silent
JOB_EVENTS all terminal skips enqueue/claim/retry rows (~half the writes)
SCHEDULER_ENABLED / SCHEDULER_TICK / SCHEDULER_LOCK_KEY true / 5s / 0x6a6f6272756e leader election is an advisory lock
JOB_RETENTION / REAPER_BATCH 168h / 500 finished jobs and their events are purged after retention
SHUTDOWN_TIMEOUT 15s stage 1 budget (gRPC + admin drain)
LOG_LEVEL / LOG_FORMAT info / json
OTEL_ENABLED / OTEL_EXPORTER_OTLP_ENDPOINT false / http://localhost:4317 traces + metrics to the devstack collector

gRPC authentication

With GRPC_TOKEN set, every gRPC call (JobService, ScheduleService, WorkerService, reflection) must carry authorization: Bearer <token>; anything else gets Unauthenticated. Only grpc.health.v1.Health stays open, for probes. The comparison is constant-time over sha256 of both sides. It is one shared token, not per-client identity, and the transport is plaintext, so the token crosses the wire in clear: put TLS (or a private network) in front when jobrund is reachable off-host. Without a token (the dev default, GRPC_ADDR=:50052 on every interface) anyone who can reach the port can enqueue, lease and read jobs; APP_ENV=prod refuses to start that way.

Admin, metrics, SSE

http://127.0.0.1:9110/admin is the embedded page (depth table + live transition log). There is no authentication on any of these routes: jobrund refuses to start with an ADMIN_ADDR that is not loopback unless ADMIN_ALLOW_REMOTE=true, and the container image (which sets it) should publish 9110 to loopback only. /admin/stats and /admin/events send no Access-Control-Allow-Origin by default, so only same-origin pages (the embedded /admin page) can read them and no other site open in the operator's browser can. To let a separate dashboard read them cross-origin, list its exact origin in ADMIN_CORS_ORIGINS; a matching Origin is echoed back, anything else gets no header (* and null are refused at startup). /admin/stats is JSON (version, uptime, depth, pool counters, outcome tally); /admin/events is SSE (event: snapshot with the depth table on connect, then event: job per transition, keepalive comments while idle); /metrics is hand-written Prometheus text: jobrun_queue_depth{queue,status} (5 s cache, never computed per scrape), jobrun_jobs_total{queue,status}, jobrun_job_duration_seconds{queue,type} histogram, jobrun_dead_total, jobrun_lease_expired_total, jobrun_lease_fenced_total, jobrun_scheduler_ticks_total, jobrun_build_info. The same obs.Snapshot feeds the OTel observable instruments when OTEL_ENABLED=true; deploy/grafana/jobrun.json is the dashboard.

Benchmark

jobrun bench is an open-loop enqueuer (ticker at 1/rate, lagged ticks are counted, never queued), then a drain watch on the cached depth and a List sweep computing finished_at - created_at (both database-clock). Pass criterion from the plan: >= 167/s sustained, p99 < 1 s, flat depth (backlog never above 2 s of the target rate). Measured on this box (Windows 11, Postgres 17 in Docker Desktop/WSL2, defaults WORKER_CONCURRENCY=16 WORKER_BATCH=32 POLL_FLOOR=200ms JOB_EVENTS=all):

run enqueued achieved completed p50 p95 p99 max max depth verdict
--rate 200 --duration 60s (gRPC) 11999 200.0/s 200.3/s 6 ms 7 ms 9 ms 45 ms 2 PASS
--rate 500 --duration 30s (gRPC) 14996 499.8/s 500.0/s 8 ms 11 ms 23 ms 40 ms 6 PASS
--rate 1000 --duration 30s --direct 30000 996.7/s 996.8/s 131 ms 170 ms 179 ms 206 ms 140 PASS

Remote workers vs in-process at 500/s (2026-09-22, loaded box). Six interleaved rounds (A B C D, 30 s each, --rate 500 --type noop) against fresh databases on the same Postgres, with other agents' workloads running alongside. A = the pre-change jobrund (e59b670), in-process pool; B = this version, in-process pool; C = this version, WORKER_QUEUES=default plus two jobworker -concurrency 8 processes on bench; D = C with the server from before f11495e (every returned credit triggered a claim).

mode completed/s median (range) p50 p99 median (range) verdict
A before, in-process 477.9 (360.6-497.1) 16 ms 54 ms (37-83) 6/6 PASS
B after, in-process 450.9 (290.6-483.6) 22 ms 67 ms (51-130) 6/6 PASS
C after, 2 remote workers 354.8 (227.2-469.3) 26 ms 100 ms (52-141) 6/6 PASS
D 2 remote workers, server before f11495e 387.8 (283.0-453.4) 25 ms 82 ms (56-180) 6/6 PASS

Read this as a latency comparison, not a throughput one. In every run the completion rate equals the achieved enqueue rate and depth stayed flat, so the workers kept up. The throughput spread is the open-loop enqueuer on a loaded box dropping ticks: 486-3092 lagged ticks median, worse in later rounds for every mode. The remote path costs roughly +10 ms p50 and +30-45 ms p99 over in-process at this rate: one stream push plus two extra RPCs (Complete, credit) per job. The fencing token and traceparent columns cost B vs A about +6 ms p50 / +13 ms p99, which is inside the spread of the loaded rounds. C vs D is within noise at this load. f11495e removes an empty claim transaction per completed job, which matters for Postgres load more than for latency.

No knob tuning was needed; the target passes with 12x headroom on the direct path. The direct row's latency is the 100 ms COPY batch tick plus the poll floor, not the queue. Knobs that matter after that: WORKER_BATCH x WORKER_CONCURRENCY (claims per poll), POLL_FLOOR, PGX_MAX_CONNS, JOB_EVENTS=terminal to halve the job_events writes.

Teardown

Ctrl-Break / SIGTERM runs six stages, each with its own budget, logged as teardown N/6: (1) health NOT_SERVING, WorkerService Drain (ends open Claim streams; remote workers keep reporting what they hold), gRPC GracefulStop and admin drain (SHUTDOWN_TIMEOUT); (2) cancel the pool: stop claiming, wait up to 30 s for in-flight handlers, whatever remains keeps its lease and is reaped later; (3) cancel scheduler (releases the advisory lock) and reaper; (4) cancel the notifier connection and depth cache; (5) close the pgx pool; (6) flush OTel.

teardown 1/6: grpc + admin drained
teardown 2/6: worker pool stopped  stats="{Claimed:56999 Succeeded:56999 Failed:0 Retried:0 Dead:0 LeaseLost:0 InFlight:0}"
teardown 3/6: scheduler + reaper stopped  reaper="{Ticks:21 Reaped:0 Purged:0}" scheduler_ticks=44
teardown 4/6: notifier + depth cache stopped
teardown 5/6: postgres pool closed
teardown 6/6: otel flushed

Testing and definition of done

Unit tests run against in-memory fakes (internal/worker, internal/scheduler, internal/reaper, internal/server and pkg/jobworker over bufconn, internal/admin, internal/cronx with DST vectors and a fuzzer); tracing tests use the OTel in-memory exporter. The store tests and the remote-worker crash tests (cmd/jobworker: kill a worker process by PID mid-job; a reaped lease reclaimed by the same worker) need the stack: task test:integration (-tags integration, TEST_DATABASE_URL). Done means go build ./..., go vet ./..., golangci-lint run ./..., go test -race -count=1 ./..., the integration tests, buf lint, and buf generate leaving gen/ unchanged.

Not done yet

  • Per-client identities on gRPC: GRPC_TOKEN is one shared bearer token, and there is no TLS (see "gRPC authentication").
  • Lost-lock detection in the scheduler beyond re-election on the next tick.
  • Priority is bounded, not weighted: with CLAIM_FIFO_EVERY=n a low-priority job is never starved, but it only advances through the 1-in-n age-ordered claims (no per-priority quotas or aging of the priority itself).
  • Per-queue concurrency limits (one WORKER_CONCURRENCY covers every queue).

License

MIT, see LICENSE.

Directories

Path Synopsis
cmd
jobrun command
Command jobrun is the CLI for a running jobrund.
Command jobrun is the CLI for a running jobrund.
jobrund command
Command jobrund is the jobrun daemon: gRPC control plane, in-process worker pool, leader-elected scheduler, lease reaper and the /admin page, all in one process.
Command jobrund is the jobrun daemon: gRPC control plane, in-process worker pool, leader-elected scheduler, lease reaper and the /admin page, all in one process.
jobworker command
Command jobworker runs jobrun handlers out of process: it leases jobs from a jobrund over jobrun.v1.WorkerService (pkg/jobworker) and runs the built-in demo handlers (noop, sleep, fail, echo).
Command jobworker runs jobrun handlers out of process: it leases jobs from a jobrund over jobrun.v1.WorkerService (pkg/jobworker) and runs the built-in demo handlers (noop, sleep, fail, echo).
gen
internal
admin
Package admin is the operator surface of jobrund: the Broker that fans job transitions out to subscribers, the /admin/events SSE feed, /admin/stats, the hand-written /metrics text exposer, the depth cache the two read from, and the embedded /admin page.
Package admin is the operator surface of jobrund: the Broker that fans job transitions out to subscribers, the /admin/events SSE feed, /admin/stats, the hand-written /metrics text exposer, the depth cache the two read from, and the embedded /admin page.
config
Package config loads the jobrund configuration from the environment.
Package config loads the jobrund configuration from the environment.
cronx
Package cronx parses standard 5-field cron expressions and computes the next firing time in a named time zone.
Package cronx parses standard 5-field cron expressions and computes the next firing time in a named time zone.
handlers
Package handlers holds the built-in job types used by demos and the benchmark: noop, sleep, fail and echo.
Package handlers holds the built-in job types used by demos and the benchmark: noop, sleep, fail and echo.
interceptor
Package interceptor holds hand-written gRPC interceptors: request id, logging and panic recovery.
Package interceptor holds hand-written gRPC interceptors: request id, logging and panic recovery.
job
Package job is the domain of the queue: the Job and Schedule types, the status transition table, the backoff policy, keyset cursors, validation, the sentinel errors, the Store and Dispatcher ports, and the handler registry.
Package job is the domain of the queue: the Job and Schedule types, the status transition table, the backoff policy, keyset cursors, validation, the sentinel errors, the Store and Dispatcher ports, and the handler registry.
jobtest
Package jobtest holds in-memory test doubles shared by the WorkerService server tests and the pkg/jobworker client tests.
Package jobtest holds in-memory test doubles shared by the WorkerService server tests and the pkg/jobworker client tests.
obs
Package obs wires OpenTelemetry traces and metrics (OTLP/gRPC push), a slog handler that stamps trace/span ids onto log records, the jobrun span helpers and the Snapshot counters that both the OTel meter and the /metrics text exposer read.
Package obs wires OpenTelemetry traces and metrics (OTLP/gRPC push), a slog handler that stamps trace/span ids onto log records, the jobrun span helpers and the Snapshot counters that both the OTel meter and the /metrics text exposer read.
postgres
Package postgres is the only package in jobrun that touches SQL.
Package postgres is the only package in jobrun that touches SQL.
reaper
Package reaper requeues expired leases (with backoff, or dead at max_attempts) and prunes finished jobs past their retention (docs/PLAN.md, step 8).
Package reaper requeues expired leases (with backoff, or dead at max_attempts) and prunes finished jobs past their retention (docs/PLAN.md, step 8).
scheduler
Package scheduler materialises cron schedules into jobs.
Package scheduler materialises cron schedules into jobs.
server
Package server is the gRPC adapter for jobrun.v1.JobService and jobrun.v1.ScheduleService: it validates request shape, converts proto <-> domain and maps domain errors to status codes.
Package server is the gRPC adapter for jobrun.v1.JobService and jobrun.v1.ScheduleService: it validates request shape, converts proto <-> domain and maps domain errors to status codes.
worker
Package worker runs claimed jobs.
Package worker runs claimed jobs.
pkg
jobworker
Package jobworker runs jobrun handlers in a separate process: it leases jobs from jobrund over jobrun.v1.WorkerService, runs them with bounded concurrency, heartbeats every lease with its fencing token and reports each outcome with Complete or Fail.
Package jobworker runs jobrun handlers in a separate process: it leases jobs from jobrund over jobrun.v1.WorkerService, runs them with bounded concurrency, heartbeats every lease with its fencing token and reports each outcome with Complete or Fail.
Package web embeds the static admin page that subscribes to the /admin/events feed.
Package web embeds the static admin page that subscribes to the /admin/events feed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL