XERJ ships a small Painless interpreter. It is not a port of Elasticsearch's
Painless and it is not a general scripting runtime. It is a purpose-built
interpreter that covers the script shapes the ES-compat suite and real
dashboards send: predicates, scoring expressions, runtime-field emit()
scripts, and ctx._source update statements.
Everything below is read out of the implementation:
- interpreter:
engine/crates/xerj-engine/src/painless.rs - HTTP entry points:
engine/crates/xerj-api/src/es_compat.rs - query parsing:
engine/crates/xerj-query/src/parser.rs
If a construct is not listed here, assume it is not implemented. The interpreter rejects what it does not understand rather than guessing.
Each row was traced from the request body to the call into the interpreter.
| Surface | Request shape | Evaluated at |
|---|---|---|
script query |
{"query": {"script": {"script": {"source": "...", "params": {}}}}} |
xerj-engine/src/index.rs:26502 |
script_score query |
{"query": {"script_score": {"query": ..., "script": {"source": "..."}}}} |
xerj-engine/src/index.rs:33162 |
function_score with a script_score function |
functions: [{"script_score": {"script": {...}}}] |
xerj-engine/src/index.rs:33162 |
script_fields |
{"script_fields": {"name": {"script": {"source": "..."}}}} |
xerj-api/src/es_compat.rs:11051 |
| Runtime fields | {"runtime_mappings": {"name": {"type": "...", "script": {"source": "emit(...)"}}}, "fields": ["name"]} |
xerj-api/src/es_compat.rs:10263, and inside top_hits at xerj-engine/src/aggs.rs:9395 |
| Script rescore | {"rescore": {"script": {"script": {"source": "..."}}}} |
xerj-engine/src/index.rs:28362 |
Script-bucketed terms agg |
{"aggs": {"a": {"terms": {"script": {"source": "..."}}}}} |
xerj-engine/src/aggs.rs:3292 |
terms_set.minimum_should_match_script |
{"terms_set": {"f": {"terms": [...], "minimum_should_match_script": {"source": "params.num_terms"}}}} |
xerj-engine/src/index.rs:26575 |
| Scripted update | POST /{index}/_update/{id} with a script |
xerj-api/src/es_compat.rs:15160 |
| Scripted update by query | POST /{index}/_update_by_query with a script |
xerj-api/src/es_compat.rs:15160 |
| Standalone execute | POST /_scripts/painless/_execute |
xerj-api/src/es_compat.rs:24974 |
Two things that look like script surfaces and are not:
- Sorting by script is not implemented.
parse_sort_field_spec(xerj-query/src/parser.rs:2833) reads field names, order, mode, missing and format. There is no_scriptsort branch, so a_scriptsort entry is treated as a field named_scriptand no script runs. - Stored Painless scripts are not supported.
PUT /_scripts/{id}(xerj-api/src/es_compat.rs:24804) stores mustache search templates, andGETreturns them with"lang": "mustache". A query that references a script byidinstead ofsourceis rejected by the parser, which requiresscript.script.source(xerj-query/src/parser.rs:1146).
| Expression | Meaning |
|---|---|
doc['field'].value, doc.field.value |
first value of the field. Errors when the field is missing, with the same message ES 7.0+ uses |
doc['field'].size(), .length, .empty |
defined on a missing field, so doc['x'].size() == 0 ? 0 : doc['x'].value is the way to guard |
params.name, params['name'] |
value from the script's params object |
params['_source'] |
the document as an object |
_score |
the document's current score |
emit(value) |
records a runtime-field value. Call it more than once for a multi-valued field |
doc[...] returns null in POST /_scripts/painless/_execute, because there is
no document in standalone execution.
- Literals: integer, float, string,
true,false,null. There are no array or map literals; a list has to arrive throughparams. - Arithmetic
+ - * / %, comparison< <= > >= == !=, logical&& || !, ternary? :, unary-and!.+concatenates when either side is a string. - Declarations
double x = ...,int,long,float,boolean,String,def,var. Types are parsed and discarded; the interpreter is dynamically typed. if (...) { ... } else { ... }, blocks,;separators, explicitreturn x;, and implicit return of the last statement's value.Math.max,min,abs,log,log10,sqrt,pow,exp,floor,ceil,round,Math.PI,Math.E(painless.rs:2490).dotProduct(params.q, 'field')over a numeric vector field, or over a literal array supplied inparams.- On strings:
.length,.toString(),.toLowerCase(),.toUpperCase(), and the date accessorsgetHour,getMinute,getSecond,getDayOfMonth,getMonthValue,getYear,getDayOfWeek,getDayOfWeekEnum,getDisplayName. On objects:.toString(),.size(),.isEmpty(), and member lookup. On arrays:.size(),.length,.isEmpty()(painless.rs:2122-2165).
Both shipped and are exercised by the limit tests. They are not described anywhere else in the docs, so in detail:
- A top-level declaration
<type> name(<type> arg, ...) { ... }becomes a callable value. Parameter and return types are parsed and discarded (painless.rs:1379). - A lambda literal is
(a, b) -> expror(a, b) -> { ... }(painless.rs:1414). It evaluates to a closure value that can be stored in a variable or passed as an argument. - A closure is invoked either by calling its name directly,
compare(a, b), or through any.method(args)call on the value:s.get(),fn.apply(x),pred.test(x). The method name is ignored and only the positional arguments matter (painless.rs:2113). That coversSupplier,Function,BiFunctionandPredicateshapes without the interpreter knowing about any of them, which is what OpenSearch's UBI dashboard scripts need. - A closure body runs in a fresh scope containing only its bound parameters. It
cannot read the caller's locals;
doc,paramsand_scorestill resolve, because they come from the evaluation context rather than the enclosing scope (painless.rs:1764).
for, while, break, continue, new and instanceof are recognised by
the tokenizer (painless.rs:469) but no statement or expression parser accepts
them, so a script using any of them fails to parse. There are no loops of any
kind. There are no array or map literals, no field assignment through
doc[...], no regular expressions, no Debug/Logger, and no Java standard
library beyond the methods listed above. Any unsupported construct is an
evaluation error, and what happens next depends on the surface (see
"Error handling" below).
These are the constants in painless.rs. They are enforced, not advisory.
| Limit | Constant | Value | Scope |
|---|---|---|---|
| Script source length | MAX_SCRIPT_LEN (:643) |
65,536 bytes (64 KiB) | per script |
| Parser nesting depth | MAX_PARSE_DEPTH (:631) |
100 | per script |
| Expression evaluation depth | MAX_EVAL_DEPTH (:638) |
500 | per script |
| Closure call nesting depth | MAX_CALL_DEPTH (:724) |
32 | per evaluation |
| Closure invocations | MAX_CALL_COUNT (:734) |
10,000 | per evaluation |
| Work-unit budget | MAX_SCRIPT_OPS (:824) |
5,000,000 | per evaluation |
| Wall-clock slice, ceiling | MAX_EVAL_SLICE (:857) |
500 ms | per evaluation |
| Wall-clock slice, floor | MIN_EVAL_SLICE (:873) |
100 ms | per evaluation |
| Single string value | MAX_PAINLESS_STRING_LEN (:653) |
1,048,576 bytes (1 MiB) | per evaluation |
| Compiled-script cache | MAX_SCRIPT_CACHE_ENTRIES / MAX_SCRIPT_CACHE_SRC_BYTES (:1571, :1577) |
128 entries, 512 KiB of source | per thread |
An evaluation is one script run against one document. A search evaluates its scripts once per document, per clause, per aggregation bucket, so read "per evaluation" literally.
The work-unit budget is the one that bounds cost rather than shape. A unit
is one interpreter step, plus one unit per 64 bytes (BYTES_PER_OP,
painless.rs:804) of any value a step produces or copies. That pricing exists
because params.x, params['_source'] and doc['x'].value each materialise a
copy behind an expression that looks like constant work. The counter is
deterministic, with no clock in it, so a trip is reproducible on any machine.
The wall-clock slice is the backstop for work the counter prices too
cheaply. Each evaluation's slice is the enclosing request's remaining time,
clamped into the 100 ms to 500 ms range above, and it is fixed before any work
is charged (PainlessCtx::new, painless.rs:158). The clock is read once per
1,024 work units (OPS_PER_CLOCK_CHECK, painless.rs:849), so an evaluation
can overshoot its deadline by one sampling window plus the step in flight.
Two figures recorded next to those constants and pinned by tests in
engine/crates/xerj-engine/tests/painless_cpu_budget.rs: the largest benign
script the 64 KiB source cap admits costs 34,407 work units, which leaves the
5,000,000 ceiling a 145x margin; and the four adversarial shapes that motivated
the budget cost 9.03 s, 37.35 s, 17.17 s and 1.13 s per document without it,
and 97 ms, 109 ms, 168 ms and 155 ms with it.
Source length, parse depth and evaluation depth are checked before anything
runs. check_script_limits (painless.rs:1665) compiles a script without
evaluating it and reports only those three, and every search entry point walks
the request body for scripts
under script and *_script keys before executing
(es_compat.rs:5221). The walked fields are exactly query, rescore,
sort, script_fields, runtime_mappings and aggs/aggregations
(GuardedField::ALL, es_compat.rs:5326), and the same set is applied on
_search, scroll and async search (through build_search_request,
es_compat.rs:5451) and on _msearch, _search/template and
_msearch/template (through the raw-body resolver, es_compat.rs:18231,
:24476, :24668).
A violation caught by that guard is a 400 before any document is touched. The exception type is not the same on every entry point, so check the status rather than the type:
_search, scroll, async search and_search/templateraiseXerjError::invalid_query, which the error layer renders as HTTP 400 with"type": "search_phase_execution_exception"and the limit message asreason(es_compat.rs:5452,:24477; mapping table inxerj-api/src/error.rs:269)._msearchand_msearch/templatefail only the offending sub-request, with{"error": {"type": "illegal_argument_exception", "reason": "..."}, "status": 400}in that item's slot and the rest of the batch still running (es_compat.rs:18231,:24668).
The remaining limits depend on the data and can only trip mid-evaluation. Those
faults are recorded into a task-local sink installed for the whole request
(with_script_fault_capture, painless.rs:977), and the handler turns the
first one into an error response instead of serving a substituted value. That
response is HTTP 400 with this body (script_limit_response,
es_compat.rs:5257):
{
"error": {
"root_cause": [{ "type": "script_exception", "reason": "<reason>" }],
"type": "script_exception",
"reason": "<reason>"
},
"status": 400
}_msearch embeds that same object as one sub-response inside its 200 envelope
(script_limit_error_value, es_compat.rs:5268).
Endpoints that run a search internally do not carry the up-front guard, but
they do refuse rather than report a truncated result when a limit trips during
matching: _count (es_compat.rs:15390), _reindex (:17738),
_delete_by_query (:20257) and _update_by_query (:20377) all return the
script_exception body instead of a count, a copy or a delete count taken over
a fail-closed subset.
The reason is one of these exact strings (is_resource_limit_error,
painless.rs:896):
| Limit | reason |
|---|---|
| Source too long, up front | compile error: script source is N bytes, exceeds the 65536-byte limit |
| Source too long, at evaluation | script source is N bytes, exceeds the 65536-byte limit |
| Parse depth | compile error: script exceeds maximum nesting depth |
| Evaluation depth | script evaluation exceeded maximum depth; split the expression into smaller statements |
| Closure call depth | script evaluation exceeded maximum closure call depth; reduce the recursion depth |
| Closure invocation count | script evaluation exceeded the maximum closure invocation count |
| Work budget | script evaluation exceeded its per-document work budget; simplify the script |
| Time budget | script evaluation exceeded its per-document time budget; simplify the script |
Once one evaluation has tripped a limit, every later evaluation inside the same
fault-capture scope returns that error immediately without running
(eval_painless, painless.rs:1701). That is what makes one budget the bound
for the whole request rather than one budget multiplied by the document count.
The scope is installed by _search (es_compat.rs:6438), by the engine's
Index::search (index.rs:12534), and by _update and _update_by_query
(es_compat.rs:14924, :20394). POST /_scripts/painless/_execute installs
no scope; it runs one script and returns any error from it, including a limit
message, as a 400 script_exception whose reason is prefixed with
cannot evaluate script: (es_compat.rs:24993).
POST /_scripts/painless/_execute is stricter and answers differently: its
source cap is 4,096 bytes and an oversize script gets HTTP 413 with
action_request_validation_exception (es_compat.rs:24911).
A script that XERJ cannot parse or evaluate, including one that uses real Painless features outside this subset, is not a 400. It degrades, per surface:
scriptquery: the document does not match. A script that returns anything other than booleantruealso does not match (index.rs:26502).script_scoreand script rescore: the score contribution becomes0.0(index.rs:33162,index.rs:28362).script_fields: the field is absent from the hit (es_compat.rs:11051).- Script-bucketed
termsagg: no buckets from that document (aggs.rs:3292). terms_set.minimum_should_match_script: the document cannot match (index.rs:26575).
This split is deliberate. A script outside the subset has to keep degrading so
that an otherwise-working request does not start failing; a resource-limit trip
is a script that was understood and refused, where a substituted 0.0 or []
would be a plausible wrong answer. The contract is covered by
engine/crates/xerj-api/tests/script_limits_http.rs, including the
"unsupported syntax still degrades quietly" case.
POST /{index}/_update/{id} and POST /{index}/_update_by_query accept a
script, but they do not run the general interpreter over the whole script.
apply_painless_update (es_compat.rs:15167) splits the source on top-level
;, and each statement must target ctx._source:
| Form | Example |
|---|---|
| Assignment | ctx._source.status = 'done' |
| Compound assignment | ctx._source.count += params.n |
| Increment and decrement | ctx._source.views++ |
| Remove a field | ctx._source.remove('tmp') |
Right-hand sides go through the interpreter, with ctx._source.x rewritten to
doc['x'].value first (es_compat.rs:15087), so params, Math.* and the
operators above all work there.
Real limitations of this path, from the same function:
- There is no control flow. Because statements are split on
;, anifblock in an update script produces fragments that do not start withctx._sourceand the request fails withunsupported update script statement: <statement>as a 400. - Other
ctx.*statements are accepted and ignored.ctx.op = 'noop'andctx.op = 'delete'parse without error and do nothing, so the document is still rewritten (es_compat.rs:15216). This differs from Elasticsearch. - An unrecognised statement is an error rather than a quiet no-op, which is the opposite of the search-path behaviour described above.
Before rc.10 these two endpoints reached the interpreter through
transform_document_serialized, which established neither a request deadline
nor a fault sink, so every statement of every document got its own full slice.
As of rc.10 (#153) both wrap the work in one deadline and one fault sink:
SCRIPTED_UPDATE_BUDGET_MS = 30_000(es_compat.rs:20362), the same 30 s_searchfalls back to when a request names no timeout._updatesets that deadline around the transform (es_compat.rs:14923)._update_by_querysets it around the whole hit loop, and the loop itself checks it per hit and breaks (es_compat.rs:20409). When it breaks early the response reports"timed_out": trueinstead of the hardcodedfalseit used to return.- A limit trip is reported once for the request rather than once per document
(
es_compat.rs:20463).
The honest residual, which is a property of the search path rather than of the
update path: the work budget and the time slice are per evaluation, meaning
per document. PainlessCtx::new builds a fresh counter and a fresh deadline
for every document (painless.rs:158), so a script tuned to stay just under
5,000,000 work units costs that much on every matched document and never trips.
What bounds a search as a whole is the request deadline, timeout in the
request or 30,000 ms by default (index.rs:12508), plus the fail-fast on the
first fault described above.
The per-evaluation slice also has a floor. Once the request deadline has
passed, each evaluation still gets MIN_EVAL_SLICE (100 ms) rather than being
cut off instantly, because the document scan only notices an expired deadline
at a document boundary and cutting evaluations off immediately would turn
ordinary slow searches into script_exception 400s
(painless.rs:859-873). So an expensive-but-legal script can continue to
consume up to 100 ms per document past the deadline until the scan stops.
Against a server started with xerj --data-dir ./data --insecure, listening on
localhost:9200.
A script query as a filter:
curl localhost:9200/orders/_search -H 'content-type: application/json' -d '{
"query": {
"script": {
"script": {
"source": "doc[\"total\"].size() == 0 ? false : doc[\"total\"].value > params.floor",
"params": { "floor": 100 }
}
}
}
}'script_score, combining the query score with a field:
curl localhost:9200/orders/_search -H 'content-type: application/json' -d '{
"query": {
"script_score": {
"query": { "match": { "title": "laptop" } },
"script": { "source": "_score * Math.log(2 + doc[\"views\"].value)" }
}
}
}'A runtime field, using a lambda as the predicate:
curl localhost:9200/orders/_search -H 'content-type: application/json' -d '{
"runtime_mappings": {
"big_order": {
"type": "boolean",
"script": {
"source": "def over = (n) -> n > 100; emit(over.test(doc[\"total\"].value))"
}
}
},
"fields": ["big_order"],
"_source": false
}'A scripted update:
curl -X POST localhost:9200/orders/_update/order-1 \
-H 'content-type: application/json' -d '{
"script": {
"source": "ctx._source.views++; ctx._source.status = params.next",
"params": { "next": "shipped" }
}
}'An update by query:
curl -X POST localhost:9200/orders/_update_by_query \
-H 'content-type: application/json' -d '{
"query": { "term": { "status": "new" } },
"script": { "source": "ctx._source.priority = 1" }
}'Standalone evaluation, which has params but no document:
curl -X POST localhost:9200/_scripts/painless/_execute \
-H 'content-type: application/json' -d '{
"script": { "source": "params.a * params.b", "params": { "a": 6, "b": 7 } }
}'The response is {"result": "42.0"}. Results are stringified, and whole
numbers keep one decimal place, matching Elasticsearch
(es_compat.rs:24976).
Compiled ASTs are cached per thread and keyed by source text, including parse
failures, because an invalid script is also re-evaluated once per document
(painless.rs:1643). The cache is bounded by both entry count and total source
bytes, since the key is caller-supplied text.
There are no published latency numbers for script execution in this repo, so
this document does not give any. The two measured figures quoted above are the
work-budget calibration numbers recorded in painless.rs and pinned by
engine/crates/xerj-engine/tests/painless_cpu_budget.rs.