backend recipe
A Job-Style Search API for a Data Grid
Last updated 29 September 2026
Not every backend answers a query in one request. Splunk's search REST API dispatches a job,
answers it in the background, and is read back once it is done, or as it runs; splunkAdapter
speaks exactly that shape. Model an async search API, a data warehouse query job, an
Elasticsearch async search, on the same three calls, and the same adapter contract applies.
The code
Pointed straight at a Splunk instance, the whole configuration is this:
import { createGrid, createPushdownSource, splunkAdapter } from '@toclocoinc/lattice-grid';
import * as compute from '@toclocoinc/lattice-grid';
const grid = createGrid(el, {
columns,
rowKey: 'event_id',
source: createPushdownSource({
adapter: splunkAdapter({
url: 'https://splunk.example.com:8089', // the management endpoint, not Splunk Web
search: 'index=security sourcetype=firewall',
earliest: '-24h',
headers: { Authorization: 'Bearer ' + token }, // or supply your own fetch for a token that expires
}),
compute,
pageSize: 100,
}),
});
// A filter or a sort becomes SPL; a filter on the time column narrows the
// search's own earliest_time/latest_time window instead of widening the query.
A filter on the grid's time column narrows the job's own earliest_time/
latest_time window rather than widening a search that reads everything and discards
most of it; every other filter becomes a search expression the job runs.
The submit, poll, fetch lifecycle
This is what "job-style" means concretely, quoted from the shipped adapter's own implementation against the search REST API: one call to create or reuse a job, one to poll it, one to read a window of its results.
// The three calls a job-style backend answers, quoted from the shipped
// adapter's own implementation against Splunk's search REST API v2:
// 1. SUBMIT - create the job, or reuse the one already dispatched for this
// exact search (so paging through a result never dispatches a second job):
const jobFor = async (plan, signal) => {
const key = keyFor(plan); // JSON.stringify([search, earliest, latest])
supersede(key); // cancel a job for a question the grid moved past
if (held) return held.sid;
const body = await json(jobsUrl, {
method: 'POST',
body: formBody(plan, { exec_mode: 'blocking' }),
signal,
});
const sid = String(body.sid || jobContent(body).sid);
held = { key, sid };
return sid;
};
// 2. POLL - read the job's status; a running job is polled again until it
// reaches a terminal state, bounded by a timeout so a job the grid gave up
// on is cancelled rather than left running forever:
const status = async (sid, signal) =>
jobContent(await json(`${jobsUrl}/${sid}?output_mode=json`, { signal }));
const pollUntilDone = async (sid, first, signal) => {
let content = first;
const deadline = Date.now() + pollTimeoutMs;
while (!isDone(content)) {
if (Date.now() > deadline) throw new Error(`job ${sid} still running after ${pollTimeoutMs}ms`);
await new Promise((r) => setTimeout(r, pollIntervalMs));
content = await status(sid, signal);
}
return content;
};
// 3. FETCH A WINDOW - read one page of a done (or still-running) job's
// results, by offset and count rather than the whole thing at once:
const results = async (sid, range, signal) => {
const params = new URLSearchParams({ output_mode: 'json' });
if (range) { params.set('offset', range.start); params.set('count', range.end - range.start); }
else params.set('count', '0'); // every result, only when the grid genuinely needs all of it
const body = await json(`${jobsUrl}/${sid}/results?${params}`, { signal });
return body.results || [];
};
A job the grid no longer wants, because a newer filter or sort superseded the question it was
answering, is deleted rather than left to run; a finished job's own result slot is released the
moment its last page is read, rather than left for the instance's TTL to reclaim. Your own
job-style API earns the same two behaviours by answering a DELETE on the job's
endpoint and by that endpoint being idempotent to call twice.
The grid never holds a credential
Every request is made with your own fetch and your own headers; the
adapter stores nothing and refreshes nothing. A thin proxy is the usual shape a browser reaches:
it holds the token, whitelists the job endpoints, and adds the one header the grid never sees.
import { createServer } from 'node:http';
const TARGET = 'https://splunk.internal:8089';
const TOKEN = process.env.SPLUNK_TOKEN;
const YOUR_INDEX = 'security'; // whatever this deployment's users may see
const ALLOWED = /^\/services\/search\/v2\/jobs(\/[^/]+)?(\/(results|export))?$/;
createServer(async (req, res) => {
const path = req.url.split('?')[0];
if (!ALLOWED.test(path)) { res.writeHead(404).end(); return; }
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE');
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
let body = Buffer.concat(chunks).toString();
if (req.method === 'POST') body = body.replace(/index=\S+/, 'index=' + YOUR_INDEX);
const upstream = await fetch(TARGET + req.url, {
method: req.method,
headers: { Authorization: 'Bearer ' + TOKEN },
body: req.method === 'GET' ? undefined : body,
});
res.writeHead(upstream.status);
res.end(Buffer.from(await upstream.arrayBuffer()));
}).listen(8080);
See the full Splunk adapter guide for reading a search live, exporting one as CSV, JSON or NDJSON, and routing events to object storage instead; or the live demo, which runs this exact submit/poll/results protocol against an in-tab mock of the search REST API, since there is no public Splunk instance this site could point a reader at.
What is pushed, what stays in the browser
A filter tree and a multi-column sort both push into the search job itself. Grouping does not:
| stats grouping is not implemented here, so a grouped grid stays ungrouped rather
than silently wrong, refused by name (pushdown:group-unsupported) rather than shown
as ordinary rows with nothing grouped. Pivoting is answered differently again: one | stats
... by pipeline per pivoted level, run as its own job and read once, which is why a pivot
works here even though a plain group does not.
The warnings a host sees
splunkAdapter: Splunk refused the token (HTTP 401). Supply a valid token through headers.
Splunk truncated this search at 50000 results, so the grid's total is the truncated count and not the number of events that matched. Narrow the search, raisemaxresultrowsin the instance'slimits.conf, or read the result inmode: "export".
Ports wanted
There is no separate downloadable server for this guide beyond the proxy above: a job-style API's submit/poll/results endpoints are defined by whatever backend you are fronting, not a store this site could ship a fixed reference copy of. For a request/response backend recipe with a full runnable server included instead, see the backend recipes repository, open to PHP, .NET and Java contributions under its own "Ports wanted" section.
See the pushdown developer guide for the full adapter surface every shape in this series shares, or stream a feed into the Data Router for the shape a job-style API cannot use: rows that arrive rather than rows you ask for.