Skip to content

Commit 0272a33

Browse files
committed
Scripting: Add char position of script errors (elastic#51069)
Add the character position of a scripting error to error responses. The contents of the `position` field are experimental and subject to change. Currently, `offset` refers to the character location where the error was encountered, `start` and `end` define a range of characters that contain the error. eg. ``` { "error": { "root_cause": [ { "type": "script_exception", "reason": "runtime error", "script_stack": [ "y = x;", " ^---- HERE" ], "script": "def x = new ArrayList(); Map y = x;", "lang": "painless", "position": { "offset": 33, "start": 29, "end": 35 } } ``` Refs: elastic#50993 * Check position only for 7.7+ * 7.7 && decrement before assign * Use correct experimental tag, update doc test responses, off by one yaml * Do not duplicate error.caused_by in replacement * Add position under causedby
1 parent 5d4bbdc commit 0272a33

File tree

7 files changed

+149
-21
lines changed

7 files changed

+149
-21
lines changed

docs/painless/painless-guide/painless-debugging.asciidoc

+2-2
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Which shows that the class of `doc.first` is
5050
"status": 400
5151
}
5252
---------------------------------------------------------
53-
// TESTRESPONSE[s/\.\.\./"script_stack": $body.error.script_stack, "script": $body.error.script, "lang": $body.error.lang, "caused_by": $body.error.caused_by, "root_cause": $body.error.root_cause, "reason": $body.error.reason/]
53+
// TESTRESPONSE[s/\.\.\./"script_stack": $body.error.script_stack, "script": $body.error.script, "lang": $body.error.lang, "position": $body.error.position, "caused_by": $body.error.caused_by, "root_cause": $body.error.root_cause, "reason": $body.error.reason/]
5454

5555
You can use the same trick to see that `_source` is a `LinkedHashMap`
5656
in the `_update` API:
@@ -85,7 +85,7 @@ The response looks like:
8585
}
8686
---------------------------------------------------------
8787
// TESTRESPONSE[s/"root_cause": \.\.\./"root_cause": $body.error.root_cause/]
88-
// TESTRESPONSE[s/\.\.\./"script_stack": $body.error.caused_by.script_stack, "script": $body.error.caused_by.script, "lang": $body.error.caused_by.lang, "caused_by": $body.error.caused_by.caused_by, "reason": $body.error.caused_by.reason/]
88+
// TESTRESPONSE[s/\.\.\./"script_stack": $body.error.caused_by.script_stack, "script": $body.error.caused_by.script, "lang": $body.error.caused_by.lang, "position": $body.error.caused_by.position, "caused_by": $body.error.caused_by.caused_by, "reason": $body.error.caused_by.reason/]
8989
// TESTRESPONSE[s/"to_string": ".+"/"to_string": $body.error.caused_by.to_string/]
9090

9191
Once you have a class you can go to <<painless-api-reference>> to see a list of

docs/reference/scripting/using.asciidoc

+11
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,14 @@ NOTE: The size of scripts is limited to 65,535 bytes. This can be
241241
changed by setting `script.max_size_in_bytes` setting to increase that soft
242242
limit, but if scripts are really large then a
243243
<<modules-scripting-engine,native script engine>> should be considered.
244+
245+
[float]
246+
[[modules-scripting-errors]]
247+
=== Script errors
248+
Elasticsearch returns error details when there is a compliation or runtime
249+
exception. The contents of this response are useful for tracking down the
250+
problem.
251+
252+
experimental[]
253+
254+
The contents of `position` are experimental and subject to change.

modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessScript.java

+6-4
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,15 @@ public interface PainlessScript {
5858
default ScriptException convertToScriptException(Throwable t, Map<String, List<String>> extraMetadata) {
5959
// create a script stack: this is just the script portion
6060
List<String> scriptStack = new ArrayList<>();
61+
ScriptException.Position pos = null;
6162
for (StackTraceElement element : t.getStackTrace()) {
6263
if (WriterConstants.CLASS_NAME.equals(element.getClassName())) {
6364
// found the script portion
64-
int offset = element.getLineNumber();
65-
if (offset == -1) {
65+
int originalOffset = element.getLineNumber();
66+
if (originalOffset == -1) {
6667
scriptStack.add("<<< unknown portion of script >>>");
6768
} else {
68-
offset--; // offset is 1 based, line numbers must be!
69+
int offset = --originalOffset; // offset is 1 based, line numbers must be!
6970
int startOffset = getPreviousStatement(offset);
7071
if (startOffset == -1) {
7172
assert false; // should never happen unless we hit exc in ctor prologue...
@@ -84,14 +85,15 @@ default ScriptException convertToScriptException(Throwable t, Map<String, List<S
8485
}
8586
pointer.append("^---- HERE");
8687
scriptStack.add(pointer.toString());
88+
pos = new ScriptException.Position(originalOffset, startOffset, endOffset);
8789
}
8890
break;
8991
// but filter our own internal stacks (e.g. indy bootstrap)
9092
} else if (!shouldFilter(element)) {
9193
scriptStack.add(element.toString());
9294
}
9395
}
94-
ScriptException scriptException = new ScriptException("runtime error", t, scriptStack, getName(), PainlessScriptEngine.NAME);
96+
ScriptException scriptException = new ScriptException("runtime error", t, scriptStack, getName(), PainlessScriptEngine.NAME, pos);
9597
for (Map.Entry<String, List<String>> entry : extraMetadata.entrySet()) {
9698
scriptException.addMetadata(entry.getKey(), entry.getValue());
9799
}

modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessScriptEngine.java

+6-4
Original file line numberDiff line numberDiff line change
@@ -439,14 +439,15 @@ private CompilerSettings buildCompilerSettings(Map<String, String> params) {
439439
private ScriptException convertToScriptException(String scriptSource, Throwable t) {
440440
// create a script stack: this is just the script portion
441441
List<String> scriptStack = new ArrayList<>();
442+
ScriptException.Position pos = null;
442443
for (StackTraceElement element : t.getStackTrace()) {
443444
if (WriterConstants.CLASS_NAME.equals(element.getClassName())) {
444445
// found the script portion
445-
int offset = element.getLineNumber();
446-
if (offset == -1) {
446+
int originalOffset = element.getLineNumber();
447+
if (originalOffset == -1) {
447448
scriptStack.add("<<< unknown portion of script >>>");
448449
} else {
449-
offset--; // offset is 1 based, line numbers must be!
450+
int offset = --originalOffset; // offset is 1 based, line numbers must be!
450451
int startOffset = getPreviousStatement(offset);
451452
int endOffset = getNextStatement(scriptSource, offset);
452453
StringBuilder snippet = new StringBuilder();
@@ -467,11 +468,12 @@ private ScriptException convertToScriptException(String scriptSource, Throwable
467468
}
468469
pointer.append("^---- HERE");
469470
scriptStack.add(pointer.toString());
471+
pos = new ScriptException.Position(originalOffset, startOffset, endOffset);
470472
}
471473
break;
472474
}
473475
}
474-
throw new ScriptException("compile error", t, scriptStack, scriptSource, PainlessScriptEngine.NAME);
476+
throw new ScriptException("compile error", t, scriptStack, scriptSource, PainlessScriptEngine.NAME, pos);
475477
}
476478

477479
// very simple heuristic: +/- 25 chars. can be improved later.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"Script errors contain position":
3+
- skip:
4+
version: " - 7.7.0"
5+
reason: "position introduced in 7.7"
6+
7+
- do:
8+
catch: /compile error/
9+
put_script:
10+
id: "1"
11+
context: "score"
12+
body: { "script": {"lang": "painless", "source": "_score * foo bar + doc['myParent.weight'].value"} }
13+
- match: { error.root_cause.0.position.offset: 13 }
14+
- match: { error.root_cause.0.position.start: 0 }
15+
- match: { error.root_cause.0.position.end: 38 }

server/src/main/java/org/elasticsearch/script/ScriptException.java

+83-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.elasticsearch.script;
22

33
import org.elasticsearch.ElasticsearchException;
4+
import org.elasticsearch.Version;
45
import org.elasticsearch.common.Strings;
56
import org.elasticsearch.common.io.stream.StreamInput;
67
import org.elasticsearch.common.io.stream.StreamOutput;
@@ -51,6 +52,7 @@ public class ScriptException extends ElasticsearchException {
5152
private final List<String> scriptStack;
5253
private final String script;
5354
private final String lang;
55+
private final Position pos;
5456

5557
/**
5658
* Create a new ScriptException.
@@ -61,13 +63,22 @@ public class ScriptException extends ElasticsearchException {
6163
* Must not be {@code null}, but can be empty (though this should be avoided if possible).
6264
* @param script Identifier for which script failed. Must not be {@code null}.
6365
* @param lang Scripting engine language, such as "painless". Must not be {@code null}.
64-
* @throws NullPointerException if any parameters are {@code null}.
66+
* @param pos Position of error within script, may be {@code null}.
67+
* @throws NullPointerException if any parameters are {@code null} except pos.
6568
*/
66-
public ScriptException(String message, Throwable cause, List<String> scriptStack, String script, String lang) {
69+
public ScriptException(String message, Throwable cause, List<String> scriptStack, String script, String lang, Position pos) {
6770
super(Objects.requireNonNull(message), Objects.requireNonNull(cause));
6871
this.scriptStack = Collections.unmodifiableList(Objects.requireNonNull(scriptStack));
6972
this.script = Objects.requireNonNull(script);
7073
this.lang = Objects.requireNonNull(lang);
74+
this.pos = pos;
75+
}
76+
77+
/**
78+
* Create a new ScriptException with null Position.
79+
*/
80+
public ScriptException(String message, Throwable cause, List<String> scriptStack, String script, String lang) {
81+
this(message, cause, scriptStack, script, lang, null);
7182
}
7283

7384
/**
@@ -78,6 +89,11 @@ public ScriptException(StreamInput in) throws IOException {
7889
scriptStack = Arrays.asList(in.readStringArray());
7990
script = in.readString();
8091
lang = in.readString();
92+
if (in.getVersion().onOrAfter(Version.V_7_7_0) && in.readBoolean()) {
93+
pos = new Position(in);
94+
} else {
95+
pos = null;
96+
}
8197
}
8298

8399
@Override
@@ -86,13 +102,24 @@ public void writeTo(StreamOutput out) throws IOException {
86102
out.writeStringArray(scriptStack.toArray(new String[0]));
87103
out.writeString(script);
88104
out.writeString(lang);
105+
if (out.getVersion().onOrAfter(Version.V_7_7_0)) {
106+
if (pos == null) {
107+
out.writeBoolean(false);
108+
} else {
109+
out.writeBoolean(true);
110+
pos.writeTo(out);
111+
}
112+
}
89113
}
90114

91115
@Override
92116
protected void metadataToXContent(XContentBuilder builder, Params params) throws IOException {
93117
builder.field("script_stack", scriptStack);
94118
builder.field("script", script);
95119
builder.field("lang", lang);
120+
if (pos != null) {
121+
pos.toXContent(builder, params);
122+
}
96123
}
97124

98125
/**
@@ -119,6 +146,13 @@ public String getLang() {
119146
return lang;
120147
}
121148

149+
/**
150+
* Returns the position of the error.
151+
*/
152+
public Position getPos() {
153+
return pos;
154+
}
155+
122156
/**
123157
* Returns a JSON version of this exception for debugging.
124158
*/
@@ -138,4 +172,51 @@ public String toJsonString() {
138172
public RestStatus status() {
139173
return RestStatus.BAD_REQUEST;
140174
}
175+
176+
public static class Position {
177+
public final int offset;
178+
public final int start;
179+
public final int end;
180+
181+
public Position(int offset, int start, int end) {
182+
this.offset = offset;
183+
this.start = start;
184+
this.end = end;
185+
}
186+
187+
Position(StreamInput in) throws IOException {
188+
offset = in.readInt();
189+
start = in.readInt();
190+
end = in.readInt();
191+
}
192+
193+
void writeTo(StreamOutput out) throws IOException {
194+
out.writeInt(offset);
195+
out.writeInt(start);
196+
out.writeInt(end);
197+
}
198+
199+
void toXContent(XContentBuilder builder, Params params) throws IOException {
200+
builder.startObject("position");
201+
builder.field("offset", offset);
202+
builder.field("start", start);
203+
builder.field("end", end);
204+
builder.endObject();
205+
}
206+
207+
@Override
208+
public boolean equals(Object o) {
209+
if (this == o)
210+
return true;
211+
if (o == null || getClass() != o.getClass())
212+
return false;
213+
Position position = (Position) o;
214+
return offset == position.offset && start == position.start && end == position.end;
215+
}
216+
217+
@Override
218+
public int hashCode() {
219+
return Objects.hash(offset, start, end);
220+
}
221+
}
141222
}

server/src/test/java/org/elasticsearch/script/ScriptExceptionTests.java

+26-9
Original file line numberDiff line numberDiff line change
@@ -35,38 +35,55 @@
3535

3636
/** Simple tests for {@link ScriptException} */
3737
public class ScriptExceptionTests extends ESTestCase {
38-
38+
3939
/** ensure we can round trip in serialization */
4040
public void testRoundTrip() throws IOException {
41-
ScriptException e = new ScriptException("messageData", new Exception("causeData"), Arrays.asList("stack1", "stack2"),
41+
ScriptException e = new ScriptException("messageData", new Exception("causeData"), Arrays.asList("stack1", "stack2"),
4242
"sourceData", "langData");
43-
43+
4444
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
4545
StreamOutput output = new DataOutputStreamOutput(new DataOutputStream(bytes));
4646
e.writeTo(output);
4747
output.close();
48-
48+
4949
StreamInput input = new InputStreamStreamInput(new ByteArrayInputStream(bytes.toByteArray()));
5050
ScriptException e2 = new ScriptException(input);
5151
input.close();
52-
52+
5353
assertEquals(e.getMessage(), e2.getMessage());
5454
assertEquals(e.getScriptStack(), e2.getScriptStack());
5555
assertEquals(e.getScript(), e2.getScript());
5656
assertEquals(e.getLang(), e2.getLang());
57+
assertNull(e.getPos());
58+
59+
// Ensure non-null position also works
60+
e = new ScriptException(e.getMessage(), e.getCause(), e.getScriptStack(), e.getScript(), e.getLang(),
61+
new ScriptException.Position(1, 0, 2));
62+
bytes = new ByteArrayOutputStream();
63+
output = new DataOutputStreamOutput(new DataOutputStream(bytes));
64+
e.writeTo(output);
65+
output.close();
66+
67+
input = new InputStreamStreamInput(new ByteArrayInputStream(bytes.toByteArray()));
68+
e2 = new ScriptException(input);
69+
input.close();
70+
assertEquals(e.getPos(), e2.getPos());
5771
}
58-
72+
5973
/** Test that our elements are present in the json output */
6074
public void testJsonOutput() {
61-
ScriptException e = new ScriptException("messageData", new Exception("causeData"), Arrays.asList("stack1", "stack2"),
62-
"sourceData", "langData");
75+
ScriptException e = new ScriptException("messageData", new Exception("causeData"), Arrays.asList("stack1", "stack2"),
76+
"sourceData", "langData", new ScriptException.Position(2, 1, 3));
6377
String json = e.toJsonString();
6478
assertTrue(json.contains(e.getMessage()));
6579
assertTrue(json.contains(e.getCause().getMessage()));
6680
assertTrue(json.contains("stack1"));
6781
assertTrue(json.contains("stack2"));
6882
assertTrue(json.contains(e.getScript()));
6983
assertTrue(json.contains(e.getLang()));
84+
assertTrue(json.contains("1"));
85+
assertTrue(json.contains("2"));
86+
assertTrue(json.contains("3"));
7087
}
7188

7289
/** ensure the script stack is immutable */
@@ -78,7 +95,7 @@ public void testImmutableStack() {
7895
});
7996
}
8097

81-
/** ensure no parameters can be null */
98+
/** ensure no parameters can be null except pos*/
8299
public void testNoLeniency() {
83100
expectThrows(NullPointerException.class, () -> {
84101
new ScriptException(null, new Exception(), Collections.emptyList(), "a", "b");

0 commit comments

Comments
 (0)