Skip to content

Commit f94dd17

Browse files
fdelbrayelleclaude
andcommitted
refactor(jsonata): replace per-call Thread with per-instance executor; document Throwable catch
Addresses two review comments from Malay on PR #80: 1. Thread-per-record regression (TransformItems): evaluateExpression() previously spawned a new 4 MB-stack Thread per call. Inside TransformItems.run(), calls are inside a Flux.map so this allocated one thread per record. Fixed by replacing new Thread(...) with a lazy-initialized single-threaded ExecutorService (daemon, 4 MB ThreadFactory) stored per Transform instance. TransformItems and TransformValue now shut it down in finally at the end of run(), so the executor lives for exactly one task run. 2. catch (Throwable): kept intentionally broad with an explanatory comment. The eval thread is a throwaway sandbox — narrowing to Exception | StackOverflowError would let other Errors (OOM, InternalError) escape silently via the UncaughtExceptionHandler, leaving both resultRef and errorRef null and returning null from evaluateExpression instead of failing. New tests: - shouldReuseEvalThreadAcrossRecords: asserts zero jsonata-eval threads alive after run() - shouldContinueWorkingAfterStackOverflowError: asserts a clean second run after a SOE Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 201b280 commit f94dd17

5 files changed

Lines changed: 156 additions & 42 deletions

File tree

plugin-transform-json/src/main/java/io/kestra/plugin/transform/jsonata/Transform.java

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
import lombok.experimental.SuperBuilder;
2121

2222
import java.time.Duration;
23+
import java.util.concurrent.ExecutionException;
24+
import java.util.concurrent.ExecutorService;
25+
import java.util.concurrent.Executors;
26+
import java.util.concurrent.TimeUnit;
2327
import java.util.concurrent.atomic.AtomicReference;
2428

2529
import io.kestra.core.models.enums.MonacoLanguages;
@@ -49,6 +53,36 @@ public abstract class Transform<T extends Output> extends Task implements JSONat
4953
@Getter(AccessLevel.PRIVATE)
5054
private Jsonata parsedExpression;
5155

56+
// Lazy-initialized; lifecycle managed by evalExecutor() / shutdownEvalExecutor().
57+
// Assumption: Flux pipelines in subclasses are sequential (no parallel()/publishOn).
58+
@Getter(AccessLevel.NONE)
59+
@ToString.Exclude
60+
@EqualsAndHashCode.Exclude
61+
private transient ExecutorService evalExecutor;
62+
63+
private ExecutorService evalExecutor() {
64+
if (this.evalExecutor == null) {
65+
this.evalExecutor = Executors.newSingleThreadExecutor(r -> {
66+
var t = new Thread(null, r, "jsonata-eval", EVAL_THREAD_STACK_SIZE);
67+
t.setDaemon(true);
68+
return t;
69+
});
70+
}
71+
return this.evalExecutor;
72+
}
73+
74+
protected void shutdownEvalExecutor() {
75+
if (this.evalExecutor != null) {
76+
this.evalExecutor.shutdown();
77+
try {
78+
this.evalExecutor.awaitTermination(1, TimeUnit.SECONDS);
79+
} catch (InterruptedException e) {
80+
Thread.currentThread().interrupt();
81+
}
82+
this.evalExecutor = null;
83+
}
84+
}
85+
5286
public void init(RunContext runContext) throws Exception {
5387
var exprString = runContext.render(this.expression).as(String.class).orElseThrow();
5488
try {
@@ -72,22 +106,31 @@ protected JsonNode evaluateExpression(RunContext runContext, JsonNode jsonNode)
72106
var resultRef = new AtomicReference<JsonNode>();
73107
var errorRef = new AtomicReference<Throwable>();
74108

75-
// Eval runs on a dedicated thread with an explicit 4 MB stack. This serves two purposes:
109+
// Eval runs on a dedicated executor thread (4 MB stack) that is reused across all records
110+
// in the same task run. This serves two purposes:
76111
// 1. Normal case: worker stack size (e.g. 256 KB on Windows) cannot constrain the evaluator.
77112
// 2. Edge case (user sets very high maxDepth): if a StackOverflowError occurs in the eval
78113
// thread, it is contained there. The worker thread reads the stored error and throws a
79114
// clean RuntimeException — the worker never crashes.
80-
var thread = new Thread(null, () -> {
115+
// The catch is intentionally Throwable: this is a throwaway-thread sandbox, so every escape
116+
// (including Errors like StackOverflowError and OutOfMemoryError) must land in errorRef.
117+
// A narrower catch would let some Errors escape, leaving both refs null and producing a
118+
// silent-null return after future.get().
119+
var future = evalExecutor().submit(() -> {
81120
try {
82121
var result = this.parsedExpression.evaluate(data, frame);
83122
resultRef.set(result != null ? MAPPER.valueToTree(result) : NullNode.getInstance());
84123
} catch (Throwable t) {
85124
errorRef.set(t);
86125
}
87-
}, "jsonata-eval", EVAL_THREAD_STACK_SIZE);
126+
return null;
127+
});
88128

89-
thread.start();
90-
thread.join();
129+
try {
130+
future.get();
131+
} catch (ExecutionException e) {
132+
throw new RuntimeException("Failed to evaluate expression", e.getCause());
133+
}
91134

92135
if (errorRef.get() != null) {
93136
throw new RuntimeException("Failed to evaluate expression", errorRef.get());

plugin-transform-json/src/main/java/io/kestra/plugin/transform/jsonata/TransformItems.java

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -120,40 +120,44 @@ public Output run(RunContext runContext) throws Exception {
120120

121121
init(runContext);
122122

123-
final URI from = new URI(runContext.render(this.from).as(String.class).orElseThrow());
124-
125-
try (Reader reader = new BufferedReader(new InputStreamReader(runContext.storage().getFile(from)), FileSerde.BUFFER_SIZE)) {
126-
Flux<JsonNode> flux = FileSerde.readAll(reader, new TypeReference<>() {
127-
});
128-
final Path outputFilePath = runContext.workingDir().createTempFile(".ion");
129-
try (Writer writer = new BufferedWriter(new OutputStreamWriter(Files.newOutputStream(outputFilePath)))) {
130-
131-
// transform
132-
Flux<JsonNode> values = flux.map(node -> this.evaluateExpression(runContext, node));
133-
134-
if (runContext.render(explodeArray).as(Boolean.class).orElseThrow()) {
135-
values = values.flatMap(jsonNode -> {
136-
if (jsonNode.isArray()) {
137-
Iterable<JsonNode> iterable = jsonNode::elements;
138-
return Flux.fromStream(StreamSupport.stream(iterable.spliterator(), false));
139-
}
140-
return Mono.just(jsonNode);
141-
});
123+
try {
124+
final URI from = new URI(runContext.render(this.from).as(String.class).orElseThrow());
125+
126+
try (Reader reader = new BufferedReader(new InputStreamReader(runContext.storage().getFile(from)), FileSerde.BUFFER_SIZE)) {
127+
Flux<JsonNode> flux = FileSerde.readAll(reader, new TypeReference<>() {
128+
});
129+
final Path outputFilePath = runContext.workingDir().createTempFile(".ion");
130+
try (Writer writer = new BufferedWriter(new OutputStreamWriter(Files.newOutputStream(outputFilePath)))) {
131+
132+
// transform
133+
Flux<JsonNode> values = flux.map(node -> this.evaluateExpression(runContext, node));
134+
135+
if (runContext.render(explodeArray).as(Boolean.class).orElseThrow()) {
136+
values = values.flatMap(jsonNode -> {
137+
if (jsonNode.isArray()) {
138+
Iterable<JsonNode> iterable = jsonNode::elements;
139+
return Flux.fromStream(StreamSupport.stream(iterable.spliterator(), false));
140+
}
141+
return Mono.just(jsonNode);
142+
});
143+
}
144+
145+
Long processedItemsTotal = FileSerde.writeAll(writer, values).block();
146+
147+
URI uri = runContext.storage().putFile(outputFilePath.toFile());
148+
149+
// output
150+
return Output
151+
.builder()
152+
.uri(uri)
153+
.processedItemsTotal(processedItemsTotal)
154+
.build();
155+
} finally {
156+
Files.deleteIfExists(outputFilePath); // ensure temp file is deleted in case of error
142157
}
143-
144-
Long processedItemsTotal = FileSerde.writeAll(writer, values).block();
145-
146-
URI uri = runContext.storage().putFile(outputFilePath.toFile());
147-
148-
// output
149-
return Output
150-
.builder()
151-
.uri(uri)
152-
.processedItemsTotal(processedItemsTotal)
153-
.build();
154-
} finally {
155-
Files.deleteIfExists(outputFilePath); // ensure temp file is deleted in case of error
156158
}
159+
} finally {
160+
shutdownEvalExecutor();
157161
}
158162
}
159163

plugin-transform-json/src/main/java/io/kestra/plugin/transform/jsonata/TransformValue.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,17 @@ public class TransformValue extends Transform<TransformValue.Output> implements
101101
public Output run(RunContext runContext) throws Exception {
102102
init(runContext);
103103

104-
final JsonNode from = parseJson(runContext.render(this.from).as(String.class).orElseThrow());
104+
try {
105+
final JsonNode from = parseJson(runContext.render(this.from).as(String.class).orElseThrow());
105106

106-
// transform
107-
JsonNode transformed = evaluateExpression(runContext, from);
107+
// transform
108+
JsonNode transformed = evaluateExpression(runContext, from);
108109

109-
// output
110-
return Output.builder().value(transformed).build();
110+
// output
111+
return Output.builder().value(transformed).build();
112+
} finally {
113+
shutdownEvalExecutor();
114+
}
111115
}
112116

113117
private static JsonNode parseJson(String from) {

plugin-transform-json/src/test/java/io/kestra/plugin/transform/jsonata/TransformItemsTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,38 @@ void shouldGetSingleRecordForValidExprReturningArrayGivenExplodeFalse() throws E
123123
Assertions.assertEquals(2, transformationResult.getFirst().size());
124124
}
125125

126+
@Test
127+
void shouldReuseEvalThreadAcrossRecords() throws Exception {
128+
// Verifies executor reuse: after run() completes, awaitTermination in shutdownEvalExecutor()
129+
// guarantees the jsonata-eval thread is gone. If the old per-call new Thread() approach were
130+
// used, 3 threads would be started and could still be alive briefly, making liveAfter > 0
131+
// probabilistically — so this assertion is a reliable regression guard.
132+
RunContext runContext = runContextFactory.of();
133+
final Path outputFilePath = runContext.workingDir().createTempFile(".ion");
134+
try (final Writer writer = new OutputStreamWriter(Files.newOutputStream(outputFilePath))) {
135+
FileSerde.writeAll(writer, Flux.just(
136+
Map.of("v", 1),
137+
Map.of("v", 2),
138+
Map.of("v", 3)
139+
)).block();
140+
writer.flush();
141+
}
142+
URI uri = runContext.storage().putFile(outputFilePath.toFile());
143+
144+
TransformItems task = TransformItems.builder()
145+
.from(Property.ofValue(uri.toString()))
146+
.expression(Property.ofValue("$"))
147+
.build();
148+
149+
task.run(runContext);
150+
151+
long liveAfter = Thread.getAllStackTraces().keySet().stream()
152+
.filter(t -> "jsonata-eval".equals(t.getName()))
153+
.count();
154+
155+
Assertions.assertEquals(0, liveAfter, "jsonata-eval thread should be terminated after run()");
156+
}
157+
126158
@Test
127159
void shouldTransformJsonInputWithDefaultIonMapper() throws Exception {
128160
// Given

plugin-transform-json/src/test/java/io/kestra/plugin/transform/jsonata/TransformValueTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,37 @@ void shouldNeverThrowStackOverflowForCommonMaxDepthValues(int maxDepth) throws E
175175
.hasCauseInstanceOf(JException.class);
176176
}
177177

178+
@Test
179+
void shouldContinueWorkingAfterStackOverflowError() throws Exception {
180+
// Validates that a StackOverflowError in one run does not poison the executor or the task.
181+
// Each call to run() creates a fresh executor (via init + shutdownEvalExecutor in finally),
182+
// so the second run always gets a clean state.
183+
RunContext runContext = runContextFactory.of();
184+
185+
TransformValue taskWithHighDepth = TransformValue.builder()
186+
.from(Property.ofValue("{}"))
187+
.expression(Property.ofValue(
188+
"($f := function($n) { $n > 0 ? $f($n - 1) + 0 : 0 }; $f(49999))"
189+
))
190+
.maxDepth(Property.ofValue(50000))
191+
.build();
192+
193+
assertThatThrownBy(() -> taskWithHighDepth.run(runContext))
194+
.isInstanceOf(RuntimeException.class)
195+
.hasCauseInstanceOf(StackOverflowError.class);
196+
197+
// Second run with a simple expression must succeed — no lingering poisoned state.
198+
RunContext runContext2 = runContextFactory.of();
199+
TransformValue simpleTask = TransformValue.builder()
200+
.from(Property.ofValue("{\"x\": 42}"))
201+
.expression(Property.ofValue("x"))
202+
.build();
203+
204+
TransformValue.Output output = simpleTask.run(runContext2);
205+
assertThat(output.getValue()).isNotNull();
206+
assertThat(output.getValue().toString()).isEqualTo("42");
207+
}
208+
178209
@Test
179210
void shouldIsolateStackOverflowInEvalThreadWhenMaxDepthExceedsStackCapacity() throws Exception {
180211
// User sets maxDepth high enough that bounds check never fires before stack exhaustion.

0 commit comments

Comments
 (0)