Skip to content
Loglinepre-release
Menu
docs/JOBS.mdEdit on GitHub

Search jobs API

POST /api/search runs a search inside the request: the caller waits for the answer, a search over a concurrency cap is refused with 429, and a client that goes away kills the query. A search job is the other shape: submit it, poll it or wait on it, read its result, cancel it. Jobs are what scripts and other programs should use, and they live under /api/v1, the versioned API the engine describes in OpenAPI 3.1 at GET /api/v1/openapi.json. The unversioned /api endpoints are the web UI's and may change with it.

A job's life#

POST /api/v1/jobs                      submit        -> 202, Location: /api/v1/jobs/{id}
GET  /api/v1/jobs/{id}?wait=10         poll or wait  -> state: queued | running | done | failed | cancelled
GET  /api/v1/jobs/{id}/results         read          -> the search response, paged, or CSV
POST /api/v1/jobs/{id}/search          slice         -> commands run over the result in the engine
DELETE /api/v1/jobs/{id}               cancel, forget

A job is submitted with the same body as POST /api/search (query, earliest, latest, limit, tz, fields, timeline) plus ttl_s, how long to keep the result once it finishes, and wait_s, which makes the submit wait up to that many seconds for the job to finish before answering, so a short search needs one call:

curl -s -H "Authorization: Bearer ll_..." -H "Content-Type: application/json" \
  https://host/api/v1/jobs -d '{"query":"index=web | stats count by status","earliest":"-1h","wait_s":10}'
{"job":{"id":"3fd50ce7b1406f4a","state":"done","user":"ci","via":"token",
 "query":"index=web | stats count by status","earliest":"-1h","latest":"now","limit":1000,
 "fields":false,"timeline":false,"ttl_s":600,
 "created":"2026-11-07T16:07:21Z","started":"2026-11-07T16:07:21Z","finished":"2026-11-07T16:07:21Z","expires":"2026-11-07T16:17:21Z",
 "queue_ahead":0,
 "result":{"events":false,"columns":["status","count"],"count":5,"limited":false,"elapsed_ms":41.2},
 "results":"/api/v1/jobs/3fd50ce7b1406f4a/results"}}

The answer is 200 when the job finished within wait_s, else 202 with the job as it is. GET /api/v1/jobs/{id} returns the same document, and ?wait=N (at most 30 seconds) waits on it the same way. A job's state:

State Meaning
queued waiting for a slot under the concurrency caps; queue_ahead is how many jobs wait ahead of it, every account's
running its search is running; started is set
done finished; result summarizes it and results is where the rows are
failed the search failed; error is the body the synchronous search would have answered with, and the status it would have had
cancelled cancelled while queued or running

GET /api/v1/jobs/{id}/results returns the search response as POST /api/search does (columns, rows, sql, limited, the histogram and field summary when asked for), with offset and count paging the rows (count=0 means all from offset on) and total the whole result's rows; format=csv returns the page as a CSV file. Before the job is done it answers 409 with the state (queued, running, cancelled) and the job; a failed job's results answer with its own error, status and body.

DELETE /api/v1/jobs/{id} drops a queued job, cancels a running one's search (its ClickHouse query is killed) and forgets a finished one's result. GET /api/v1/jobs lists the caller's jobs newest first, and the counts of queued, running and kept jobs host-wide; an admin's list has every account's. A job is visible to the account that submitted it and to admins; anyone else's reads as 404, so ids do not leak.

Searching a result#

POST /api/v1/jobs/{id}/search runs a pipeline of commands over a done job's result in the engine, without another ClickHouse query: the job's rows are the input, and the answer is a search response (columns, rows, count, limited, residual, and the range and sql of the job's own search). The body is the commands, starting with a pipe:

curl -s -H "Authorization: Bearer ll_..." -H "Content-Type: application/json" \
  https://host/api/v1/jobs/3fd50ce7b1406f4a/search -d '{"query":"| where count > 10 | stats sum(count) AS total"}'

Every command with an in-process form is allowed (COMPATIBILITY.md, "In-process execution"); one without answers 400 (kind plan), a query that does not parse 400 (kind parse), and a job that is not done answers as its results would. The job's own result is unchanged, so one job can be sliced many times: this is how a dashboard runs a base search once and gives each panel its own view of it (DASHBOARDS.md).

A slice sees what the job's caps let through. A result that ends in a transforming command (stats, timechart) is complete; raw events are at most limit of them, newest first, and when limited is true a slice aggregates only those. The engine's rows cap applies to a slice's output as it does to a search's.

Queueing and limits#

A job runs under the same caps as a request's search (SEARCH-LIMITS.md): the host's concurrency cap and the account's own. Where a request over a cap is refused, a job waits. Jobs start in submission order; one whose account's cap is full is passed over, so one account's backlog does not hold the others up, and tried again when a slot frees. A running job is a running search: it shows in GET /api/searches and on the Health page, its query id is the job id, and DELETE /api/searches/{id} cancels it as it would any search. It runs as the account that submitted it, with that account's event, row and byte caps, and the search profile's limits apply to its query.

A finished job keeps its result for ttl_s seconds (default 600, at most 3600) and is then dropped; results live in the engine's memory and are bounded by the result caps, so a job never holds more than a request could return. An account holds at most 50 jobs at once in any state: over that, its oldest finished jobs are dropped to make room, and when every held job is still queued or running the submit is refused:

HTTP/1.1 429 Too Many Requests
Retry-After: 5

{"cap":50,"error":"too many search jobs for ci: 50 of 50 queued or running; wait for one to finish, or cancel or delete one",
 "held":50,"kind":"busy","queue_scope":"user","retry_after_s":5,"scope":"queue"}

The host holds at most 1,000 jobs in all, the same way (queue_scope global). Jobs do not survive an engine restart: on shutdown every queued and running job is cancelled and its query killed, and nothing is kept.

Authentication and audit#

Every endpoint takes a session or an API token, search-scoped tokens included; a session must send X-Logline-Client on the submit and the delete, as on every state-changing request (AUTH.md). A readonly account may run jobs, as it may search.

Where What
index=_audit, event=job.create who submitted what: target is the job id, details the query, range, limit and ttl_s
index=_audit, event=search the job's search as it ran: the same line a request's search writes, with details.job and details.query_id the job id, under the request id of the submit
index=_audit, event=search, details.in_process=true a slice of a job's result: details.job the job id, details.query the commands, details.residual the steps as they ran, details.count and details.elapsed_ms
index=_audit, event=job.cancel a queued or running job deleted; details.state is what it was
index=_audit, event=job.delete a finished job deleted
index=_audit, event=job.rejected a submit refused because the account or the host held its cap of jobs
index=_audit event=search details.job=* | stats avg(details.elapsed_ms), count by actor

Not yet#

  • A jobs view in the UI, and the search page running on jobs rather than the synchronous endpoint (the dashboards run their shared base searches as jobs already).
  • Jobs that outlive the engine: results kept on disk or in ClickHouse, with a job history to search.
  • Progress while a job runs (rows read, bytes scanned) from ClickHouse's system.processes.
  • Saved searches and alerts on top of jobs (phase 1b).
  • Streaming a large result rather than paging a result held in memory.
  • A Splunk-shaped jobs endpoint for Splunk SDKs and scripts, pending counsel (DESIGN.md, "Questions for counsel" 3).