Skip to content

Declare a background job

Declare a typed job in .atl, implement its handler in your own service, and run the submit-claim-complete loop.

Declare a typed job in .atl, implement its handler in your own service, and run the submit–claim–complete loop.

1. Declare the job

In your repository’s .atl:

job ImportContacts in directory {
  args {
    account_id      varchar(64) not null
    import_strategy varchar(20) not null default "skip"
  }
  retries  3
  timeout  30m
  queue    "contacts"
  visible_to "directory"
}
  • args uses the entity field grammar (varchar, int, jsonb, arrays).
  • visible_to "directory" restricts which caller may submit the job and claim it.

2. Apply

tide apply

The declaration is additive. The server enforces retries, timeout, and queue at submit and claim time.

3. Generate, implement, and register the handler

tide generate

Alongside the entity clients, tide generate writes a jobs module per namespace — client/<ns>/jobs.go for Go, atlantis/<ns>/v1/jobs.py for Python — with a typed args type, a handler type, and a Register<Job> helper per declared job. Handlers run in your service’s binary:

type importContactsHandler struct{ crm *crm.Client }

func (h *importContactsHandler) Handle(ctx context.Context, args directory.ImportContactsArgs) error {
    jobs.Checkpoint(ctx, 10, "fetching contacts")
    // ... your import logic, reading args.AccountId ...
    return nil
}

registry := jobs.NewRegistry()
directory.RegisterImportContacts(registry, &importContactsHandler{crm: crmClient})
from atlantis.directory.v1 import ImportContactsArgs, register_import_contacts
from atlantis_client.jobs import Registry, checkpoint


def handle(args: ImportContactsArgs) -> None:
    checkpoint(10, "fetching contacts")
    # ... your import logic, reading args.account_id ...


registry = Registry()
register_import_contacts(registry, handle)

Python raises rather than returning an error, and the args arrive as a frozen dataclass built from the row’s JSON. A timestamptz arg is an RFC 3339 str there, not a datetime — job args follow the JSON projection, which the type mapping and atlantis_client.jobs.argjson cover.

The helper decodes the row’s args JSON into the typed value before invoking your handler. (Registering under the raw id "directory.ImportContacts" and decoding yourself works the same way.)

A non-nil return retries the attempt up to the declared retries, then dead-letters it. jobs.Checkpoint(ctx, pct, msg) reports progress and extends the attempt’s lease; long-running handlers call it as they go.

4. Run a worker

The worker holds a gRPC session to your organisation’s server and receives dispatched work over it — your service needs no database access:

w := jobs.NewDispatchedWorker(conn, registry, "contacts", jobs.ServerConfig{
    Logger: slog.Default(),
})
go w.Run(ctx)

conn is your service’s authenticated *grpc.ClientConn to atlantis.

from atlantis_client import connect_from_store
from atlantis_client.jobs import DispatchedWorker, WorkerConfig

with connect_from_store("acme", "directory") as channel:
    DispatchedWorker(channel, registry, WorkerConfig(queue="contacts")).run()

run() blocks; stop() from another thread ends it. Handlers run on a thread pool sized by WorkerConfig.max_in_flight.

The queue must be one a declared job runs on ("default" when no job names one) — an unknown queue is refused at session open. Both workers reconnect on stream errors with backoff; work in flight when a worker dies is re-dispatched to another worker after its lease expires, so handlers must be idempotent.

5. Submit

tide job submit directory.ImportContacts \
  --args='{"account_id":"acct_123","import_strategy":"replace"}'
✔ submitted directory.ImportContacts as job 0198f2c1a4e07000
       monitor with: tide job status 0198f2c1a4e07000

Services submit through the same gRPC API (SubmitJob, requiring the JOBS_WRITE capability), or atomically from a procedure:

procedure ConnectAccount for directory.Account {
  input { account_id: varchar(64) }
  steps {
    update Account set status = "connected" where account_id = $account_id
    enqueue directory.ImportContacts(account_id: $account_id, import_strategy: "replace")
  }
}

The enqueue shares the procedure’s transaction: if the procedure rolls back, the job is never enqueued. The procedure’s RPC answers from the next request after tide apply — see Custom queries and procedures.

Verify

tide job status 0198f2c1a4e07000tideout
job-id     0198f2c1a4e07000
job-name   directory.ImportContacts
queue      contacts
status     complete
attempts   1 / 3
enqueued   2026-09-02T14:03:11Z

The status reaches complete, with any checkpoints your handler reported. The console’s Workers page shows the connected worker session and its queue.

Next steps

Navigation

Type to search…

↑↓ navigate↵ selectEsc close