From 985386989d88894da7758ef9cef4ee10009933f0 Mon Sep 17 00:00:00 2001 From: wwang Date: Mon, 13 Jul 2026 11:18:44 +0800 Subject: [PATCH 01/21] fix(QTDI-2709): Add ProcessorBufferingTest to evaluate memory consumption during record emission --- .../components/ProcessorBufferingTest.java | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java new file mode 100644 index 0000000000000..299b97edfc068 --- /dev/null +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java @@ -0,0 +1,372 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.runtime.di.beam.components; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import javax.json.bind.Jsonb; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.talend.sdk.component.api.processor.AfterGroup; +import org.talend.sdk.component.api.processor.BeforeGroup; +import org.talend.sdk.component.api.processor.ElementListener; +import org.talend.sdk.component.api.processor.Input; +import org.talend.sdk.component.api.processor.LastGroup; +import org.talend.sdk.component.api.processor.Output; +import org.talend.sdk.component.api.processor.OutputEmitter; +import org.talend.sdk.component.api.processor.Processor; +import org.talend.sdk.component.api.record.Record; +import org.talend.sdk.component.api.service.record.RecordBuilderFactory; +import org.talend.sdk.component.runtime.di.AutoChunkProcessor; +import org.talend.sdk.component.runtime.di.InputsHandler; +import org.talend.sdk.component.runtime.di.JobStateAware; +import org.talend.sdk.component.runtime.di.OutputsHandler; +import org.talend.sdk.component.runtime.manager.ComponentManager; +import org.talend.sdk.component.runtime.output.InputFactory; +import org.talend.sdk.component.runtime.output.OutputFactory; +import org.talend.sdk.component.runtime.record.RecordConverters; + +import lombok.Getter; +import lombok.ToString; + +/** + * Manual test to observe processor output buffering problem (QTDI-2709). + * + * Run this test to see memory consumption when a processor emits many records. + * With the current push model, ALL records are buffered in memory before the + * consumer can drain them. + * + * Expected behavior on current code: test FAILS (memory exceeds threshold). + * After iterator pattern is implemented: test PASSES (memory stays low). + */ +class ProcessorBufferingTest { + + protected static RecordBuilderFactory builderFactory; + + // Large enough to create observable memory pressure + static final int RECORD_COUNT = 100_000; + + // Memory threshold: if buffering all records uses more than this, the test fails. + // 100K records with strings should use >10MB when fully buffered. + // With streaming, memory should stay well under this. + static final long MAX_MEMORY_DELTA_MB = 5; + + @BeforeAll + static void forceManagerInit() { + final ComponentManager manager = ComponentManager.instance(); + if (manager.find(Stream::of).count() == 0) { + manager.addPlugin(new File("target/test-classes").getAbsolutePath()); + } + } + + /** + * Tests memory consumption when a processor emits 100K records in @ElementListener. + * + * FAILS on current code: all 100K records buffered → memory spike > threshold. + * PASSES after iterator implementation: records stream lazily → memory stays low. + */ + @Test + void elementListenerShouldNotBufferAllRecordsInMemory() { + final ComponentManager manager = ComponentManager.instance(); + final Map, Object> servicesMapper = getServicesMapper(manager); + final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); + builderFactory = (RecordBuilderFactory) servicesMapper.get(RecordBuilderFactory.class); + + final org.talend.sdk.component.runtime.output.Processor processor = manager + .findProcessor("ProcessorBufferingTest", "heavyEmitter", 1, new HashMap<>()) + .orElseThrow(() -> new IllegalStateException("processor not found")); + JobStateAware.init(processor, new HashMap<>()); + + final AutoChunkProcessor chunkProcessor = new AutoChunkProcessor(RECORD_COUNT + 1, processor); + chunkProcessor.start(); + + final InputsHandler inputsHandler = new InputsHandler(jsonb, servicesMapper); + inputsHandler.addConnection("FLOW", row1Struct.class); + + final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); + outputsHandler.addConnection("FLOW", row1Struct.class); + + final InputFactory inputFactory = inputsHandler.asInputFactory(); + final OutputFactory outputFactory = outputsHandler.asOutputFactory(); + + // Prepare one input record + final Record inputRecord = builderFactory.newRecordBuilder() + .withString("id", "input-1") + .withString("name", "trigger") + .build(); + final RecordConverters.MappingMetaRegistry registry = new RecordConverters.MappingMetaRegistry(); + final row1Struct inputRow = (row1Struct) registry.find(row1Struct.class).newInstance(inputRecord); + inputsHandler.setInputValue("FLOW", inputRow); + + // Force GC to get a clean baseline + System.gc(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + final long memoryBefore = usedMemoryMB(); + + // --- Producer emits all records in onElement() --- + chunkProcessor.onElement(inputFactory, outputFactory); + + chunkProcessor.flush(outputFactory); + chunkProcessor.stop(); + + // Measure memory AFTER production, BEFORE drain + final long memoryAfterProduction = usedMemoryMB(); + final long memoryDelta = memoryAfterProduction - memoryBefore; + + System.out.println("=== ProcessorBufferingTest: @ElementListener ==="); + System.out.println("Records emitted: " + RECORD_COUNT); + System.out.println("Memory before onElement(): " + memoryBefore + " MB"); + System.out.println("Memory after onElement(): " + memoryAfterProduction + " MB"); + System.out.println("Memory delta: " + memoryDelta + " MB"); + System.out.println("Threshold: " + MAX_MEMORY_DELTA_MB + " MB"); + + // Drain after onElement — same as Studio generated code + int drainedCount = 0; + while (outputsHandler.hasMoreData()) { + outputsHandler.getValue("FLOW"); + drainedCount++; + } + System.out.println("Records drained: " + drainedCount); + + // ASSERTION: memory delta should be under threshold + // FAILS on current code (all records buffered → large delta) + // PASSES after iterator implementation (lazy streaming → small delta) + assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB (streaming), " + + "but was " + memoryDelta + " MB (all records buffered in memory)"); + } + + /** + * Tests memory consumption when a processor emits 100K records in @AfterGroup. + * + * FAILS on current code: all records buffered in @AfterGroup. + * PASSES after iterator implementation. + */ + @Test + void afterGroupShouldNotBufferAllRecordsInMemory() { + final ComponentManager manager = ComponentManager.instance(); + final Map, Object> servicesMapper = getServicesMapper(manager); + final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); + builderFactory = (RecordBuilderFactory) servicesMapper.get(RecordBuilderFactory.class); + + final org.talend.sdk.component.runtime.output.Processor processor = manager + .findProcessor("ProcessorBufferingTest", "heavyAfterGroupEmitter", 1, new HashMap<>()) + .orElseThrow(() -> new IllegalStateException("processor not found")); + JobStateAware.init(processor, new HashMap<>()); + + // chunkSize=5: after 5 inputs, @AfterGroup fires and emits 100K records + final AutoChunkProcessor chunkProcessor = new AutoChunkProcessor(5, processor); + chunkProcessor.start(); + + final InputsHandler inputsHandler = new InputsHandler(jsonb, servicesMapper); + inputsHandler.addConnection("FLOW", row1Struct.class); + + final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); + outputsHandler.addConnection("MAIN", row1Struct.class); + outputsHandler.addConnection("REJECT", row1Struct.class); + + final InputFactory inputFactory = inputsHandler.asInputFactory(); + final OutputFactory outputFactory = outputsHandler.asOutputFactory(); + + final RecordConverters.MappingMetaRegistry registry = new RecordConverters.MappingMetaRegistry(); + + int mainCount = 0; + int rejectCount = 0; + + // Force GC to get a clean baseline + System.gc(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + final long memoryBefore = usedMemoryMB(); + + // Feed 5 inputs (triggers @AfterGroup) + for (int i = 0; i < RECORD_COUNT; i++) { + final Record inputRecord = builderFactory.newRecordBuilder() + .withString("id", "input-" + i) + .withString("name", "record-" + i) + .build(); + final row1Struct inputRow = (row1Struct) registry.find(row1Struct.class).newInstance(inputRecord); + inputsHandler.setInputValue("FLOW", inputRow); + + chunkProcessor.onElement(inputFactory, outputFactory); + + // Drain after each onElement — same as Studio generated code + while (outputsHandler.hasMoreData()) { + if (outputsHandler.getValue("MAIN") != null) { + mainCount++; + } + if (outputsHandler.getValue("REJECT") != null) { + rejectCount++; + } + } + } + + chunkProcessor.flush(outputFactory); + chunkProcessor.stop(); + + final long memoryAfterGroup = usedMemoryMB(); + final long memoryDelta = memoryAfterGroup - memoryBefore; + + System.out.println("=== ProcessorBufferingTest: @AfterGroup ==="); + System.out.println("Records emitted: " + RECORD_COUNT); + System.out.println("Memory before: " + memoryBefore + " MB"); + System.out.println("Memory after @AfterGroup: " + memoryAfterGroup + " MB"); + System.out.println("Memory delta: " + memoryDelta + " MB"); + + while (outputsHandler.hasMoreData()) { + if (outputsHandler.getValue("MAIN") != null) { + mainCount++; + } + if (outputsHandler.getValue("REJECT") != null) { + rejectCount++; + } + } + + System.out.println("MAIN drained: " + mainCount); + System.out.println("REJECT drained: " + rejectCount); + + // Verify all records were produced + assertTrue(mainCount + rejectCount > 0, + "Should have produced records in @AfterGroup"); + + // ASSERTION: memory delta should be under threshold + // FAILS on current code (all records buffered → large delta) + // PASSES after iterator implementation (lazy streaming → small delta) + assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB (streaming), " + + "but was " + memoryDelta + " MB (all records buffered in memory)"); + } + + // --- Helpers --- + + private static long usedMemoryMB() { + final Runtime rt = Runtime.getRuntime(); + return (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024); + } + + private Map, Object> getServicesMapper(ComponentManager manager) { + return manager + .findPlugin("test-classes") + .get() + .get(ComponentManager.AllServices.class) + .getServices(); + } + + // --- Test components --- + + /** + * Processor that emits 100K records in @ElementListener via emit(). + * Each record has enough data to create observable memory pressure. + */ + @Processor(name = "heavyEmitter", family = "ProcessorBufferingTest") + public static class HeavyEmitterProcessor implements Serializable { + + @ElementListener + public void onElement(@Input final Record input, + @Output final OutputEmitter output) { + for (int i = 0; i < RECORD_COUNT; i++) { + final Record record = builderFactory.newRecordBuilder() + .withString("id", "out-" + i) + .withString("name", "generated-record-with-some-payload-" + i) + .withString("data", "additional-field-to-increase-memory-footprint-" + i) + .build(); + output.emit(record); + } + } + } + + /** + * Processor that accumulates inputs, then emits 100K records in @AfterGroup. + * Simulates the N:M batch pattern. + */ + @Processor(name = "heavyAfterGroupEmitter", family = "ProcessorBufferingTest") + public static class HeavyAfterGroupEmitterProcessor implements Serializable { + + private int inputCount = 0; + + @BeforeGroup + public void beforeGroup() { + // no-op + } + + @ElementListener + public void onElement(@Input final Record input) { + inputCount++; + } + + @AfterGroup + public void afterGroup(@Output("MAIN") final OutputEmitter main, + @Output("REJECT") final OutputEmitter reject, + @LastGroup final boolean lastGroup) { + if (!lastGroup) { + return; + } + + for (int i = 0; i < RECORD_COUNT; i++) { + final Record record = builderFactory.newRecordBuilder() + .withString("id", "result-" + i) + .withString("name", "processed-record-with-payload-" + i) + .withString("data", "bulk-result-data-field-" + i) + .build(); + if (i % 10 == 0) { + reject.emit(record); + } else { + main.emit(record); + } + } + inputCount = 0; + } + } + + // --- Row struct --- + + @Getter + @ToString + public static class row1Struct implements routines.system.IPersistableRow { + + public String id; + + public String name; + + public String data; + + @Override + public void writeData(final ObjectOutputStream objectOutputStream) { + throw new UnsupportedOperationException("#writeData()"); + } + + @Override + public void readData(final ObjectInputStream objectInputStream) { + throw new UnsupportedOperationException("#readData()"); + } + } +} From 9aecf24a831f908e2612ec6ffd17173510f83b38 Mon Sep 17 00:00:00 2001 From: wwang Date: Mon, 13 Jul 2026 14:01:30 +0800 Subject: [PATCH 02/21] fix(QTDI-2709): do real implement --- .../sdk/component/api/processor/Output.java | 2 + .../api/processor/OutputIterator.java | 55 +++++++++ .../runtime/output/ProcessorImpl.java | 17 ++- .../runtime/visitor/ModelVisitor.java | 11 +- .../component/runtime/di/BaseIOHandler.java | 29 ++++- .../component/runtime/di/OutputsHandler.java | 64 ++++++++-- .../components/ProcessorBufferingTest.java | 112 +++++++++++++----- 7 files changed, 242 insertions(+), 48 deletions(-) create mode 100644 component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java index a6fde9f1e70b2..25748a032d2c1 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java @@ -26,4 +26,6 @@ public @interface Output { String value() default "__default__"; + + boolean iterator() default false; } diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java new file mode 100644 index 0000000000000..5804dc8e6bf67 --- /dev/null +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java @@ -0,0 +1,55 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.api.processor; + +import java.util.Iterator; + +/** + * Allows a processor to provide a lazy iterator for output records + * instead of pushing them via {@link OutputEmitter#emit(Object)}. + * + *

+ * Used with {@code @Output(iterator = true)} to enable streaming: + * records are produced on-demand during the consumer's drain loop, + * rather than buffered entirely in memory. + * + *

+ * Example usage in a processor: + * + *

+ * {@code
+ * 
+ * @ElementListener
+ * public void process(@Input Record input,
+ *         @Output(iterator = true) OutputIterator output) {
+ *     output.setIterator(myLazyIterator(input));
+ * }
+ * }
+ * 
+ * + * @param the record type + */ +public interface OutputIterator { + + /** + * Sets the lazy iterator that will produce output records on demand. + * Call this once per invocation; the consumer will pull records + * by calling {@link Iterator#hasNext()} and {@link Iterator#next()}. + * + * @param iterator the lazy iterator providing output records + */ + void setIterator(Iterator iterator); +} diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index 81229696456b1..c69f8b0eef463 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -56,6 +56,7 @@ import org.talend.sdk.component.api.processor.Input; import org.talend.sdk.component.api.processor.LastGroup; import org.talend.sdk.component.api.processor.Output; +import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; import org.talend.sdk.component.runtime.base.Delegated; import org.talend.sdk.component.runtime.base.LifecycleImpl; @@ -150,10 +151,11 @@ public void beforeGroup() { private BiFunction buildProcessParamBuilder(final Parameter parameter) { if (parameter.isAnnotationPresent(Output.class)) { - return (inputs, outputs) -> { - final String name = parameter.getAnnotation(Output.class).value(); - return outputs.create(name); - }; + final Output annotation = parameter.getAnnotation(Output.class); + if (annotation.iterator()) { + return (inputs, outputs) -> (OutputIterator) outputs.create(annotation.value()); + } + return (inputs, outputs) -> outputs.create(annotation.value()); } final Class parameterType = parameter.getType(); @@ -167,8 +169,11 @@ private Function toOutputParamBuilder(final Parameter par if (parameter.isAnnotationPresent(LastGroup.class)) { return false; } - final String name = parameter.getAnnotation(Output.class).value(); - return outputs.create(name); + final Output annotation = parameter.getAnnotation(Output.class); + if (annotation.iterator()) { + return (OutputIterator) outputs.create(annotation.value()); + } + return outputs.create(annotation.value()); }; } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java index 1b52f1b9ff546..8514226d1db9c 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java @@ -46,6 +46,7 @@ import org.talend.sdk.component.api.processor.LastGroup; import org.talend.sdk.component.api.processor.Output; import org.talend.sdk.component.api.processor.OutputEmitter; +import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.processor.Processor; import org.talend.sdk.component.api.standalone.DriverRunner; import org.talend.sdk.component.api.standalone.RunAtDriver; @@ -195,7 +196,8 @@ private void validateProcessor(final Class input) { afterGroups.forEach(m -> { final List invalidParams = Stream.of(m.getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { - throw new IllegalArgumentException("@Output parameter must be of type OutputEmitter"); + throw new IllegalArgumentException( + "@Output parameter must be of type OutputEmitter or OutputIterator (with iterator = true)"); } }) .filter(p -> !p.isAnnotationPresent(Output.class)) @@ -243,7 +245,8 @@ private void validateProducer(final Class input, final List afterGrou if (!producers.isEmpty() && Stream.of(producers.get(0).getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { - throw new IllegalArgumentException("@Output parameter must be of type OutputEmitter"); + throw new IllegalArgumentException( + "@Output parameter must be of type OutputEmitter or OutputIterator (with iterator = true)"); } }).filter(p -> !p.isAnnotationPresent(Output.class)).count() < 1) { throw new IllegalArgumentException(input + " doesn't have the input parameter on its producer method"); @@ -254,6 +257,10 @@ private boolean validOutputParam(final Parameter p) { if (!(p.getParameterizedType() instanceof ParameterizedType pt)) { return false; } + final Output annotation = p.getAnnotation(Output.class); + if (annotation != null && annotation.iterator()) { + return OutputIterator.class == pt.getRawType(); + } return OutputEmitter.class == pt.getRawType(); } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index 9b7441255171f..4a5f37b4478f2 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -91,19 +91,32 @@ static class IO { private final Class type; + private Iterator source; + + void setSource(final Iterator source) { + // Close previous source if it implements AutoCloseable + closeSource(); + this.source = source; + } + private void reset() { values.clear(); + closeSource(); + this.source = null; } boolean hasNext() { - return values.size() != 0; + return !values.isEmpty() + || (source != null && source.hasNext()); } T next() { - if (hasNext()) { + if (!values.isEmpty()) { return type.cast(values.poll()); } - + if (source != null && source.hasNext()) { + return type.cast(source.next()); + } return null; } @@ -114,6 +127,16 @@ void add(final T e) { Class getType() { return type; } + + private void closeSource() { + if (source instanceof AutoCloseable) { + try { + ((AutoCloseable) source).close(); + } catch (final Exception e) { + // best effort cleanup + } + } + } } } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index 13bbee78e8ce9..3eb9b1d2e3662 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -15,10 +15,13 @@ */ package org.talend.sdk.component.runtime.di; +import java.util.Iterator; import java.util.Map; import javax.json.bind.Jsonb; +import org.talend.sdk.component.api.processor.OutputEmitter; +import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.record.Record; import org.talend.sdk.component.api.record.Schema; import org.talend.sdk.component.runtime.output.OutputFactory; @@ -33,17 +36,9 @@ public OutputsHandler(final Jsonb jsonb, final Map, Object> servicesMap } public OutputFactory asOutputFactory() { - return name -> value -> { + return name -> { final BaseIOHandler.IO ref = connections.get(getActualName(name)); - if (ref != null && value != null) { - if (value instanceof javax.json.JsonValue) { - ref.add(jsonb.fromJson(value.toString(), ref.getType())); - } else if (value instanceof Record record) { - ref.add(registry.find(ref.getType()).newInstance(record)); - } else { - ref.add(jsonb.fromJson(jsonb.toJson(value), ref.getType())); - } - } + return new OutputEmitterWithIterator(ref); }; } @@ -70,4 +65,53 @@ public OutputFactory asOutputFactoryForGuessSchema() { }; } + private Object convert(final Object value, final BaseIOHandler.IO ref) { + if (value == null) { + return null; + } else if (value instanceof javax.json.JsonValue) { + return jsonb.fromJson(value.toString(), ref.getType()); + } else if (value instanceof Record record) { + return registry.find(ref.getType()).newInstance(record); + } else { + return jsonb.fromJson(jsonb.toJson(value), ref.getType()); + } + } + + /** + * Internal class implementing both OutputEmitter and OutputIterator. + * Allows ProcessorImpl to cast to OutputIterator when iterator mode is active. + */ + private class OutputEmitterWithIterator implements OutputEmitter, OutputIterator { + + private final BaseIOHandler.IO ref; + + OutputEmitterWithIterator(final BaseIOHandler.IO ref) { + this.ref = ref; + } + + @Override + public void emit(final Object value) { + if (ref != null && value != null) { + ref.add(convert(value, ref)); + } + } + + @Override + public void setIterator(final Iterator iterator) { + if (ref != null) { + ref.setSource(new Iterator() { + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Object next() { + return convert(iterator.next(), ref); + } + }); + } + } + } } diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java index 299b97edfc068..062f245d65072 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java @@ -35,7 +35,7 @@ import org.talend.sdk.component.api.processor.Input; import org.talend.sdk.component.api.processor.LastGroup; import org.talend.sdk.component.api.processor.Output; -import org.talend.sdk.component.api.processor.OutputEmitter; +import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.processor.Processor; import org.talend.sdk.component.api.record.Record; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; @@ -233,6 +233,14 @@ void afterGroupShouldNotBufferAllRecordsInMemory() { chunkProcessor.flush(outputFactory); chunkProcessor.stop(); + // GC to reclaim input record objects from the loop + System.gc(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + final long memoryAfterGroup = usedMemoryMB(); final long memoryDelta = memoryAfterGroup - memoryBefore; @@ -284,29 +292,41 @@ private Map, Object> getServicesMapper(ComponentManager manager) { // --- Test components --- /** - * Processor that emits 100K records in @ElementListener via emit(). - * Each record has enough data to create observable memory pressure. + * Processor that provides a lazy iterator in @ElementListener. + * Records are produced on-demand during the drain loop. */ @Processor(name = "heavyEmitter", family = "ProcessorBufferingTest") public static class HeavyEmitterProcessor implements Serializable { @ElementListener public void onElement(@Input final Record input, - @Output final OutputEmitter output) { - for (int i = 0; i < RECORD_COUNT; i++) { - final Record record = builderFactory.newRecordBuilder() - .withString("id", "out-" + i) - .withString("name", "generated-record-with-some-payload-" + i) - .withString("data", "additional-field-to-increase-memory-footprint-" + i) - .build(); - output.emit(record); - } + @Output(iterator = true) final OutputIterator output) { + output.setIterator(new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + return index < RECORD_COUNT; + } + + @Override + public Record next() { + final Record record = builderFactory.newRecordBuilder() + .withString("id", "out-" + index) + .withString("name", "generated-record-with-some-payload-" + index) + .withString("data", "additional-field-to-increase-memory-footprint-" + index) + .build(); + index++; + return record; + } + }); } } /** - * Processor that accumulates inputs, then emits 100K records in @AfterGroup. - * Simulates the N:M batch pattern. + * Processor that accumulates inputs, then sets lazy iterators on MAIN and REJECT + * in @AfterGroup. Simulates the N:M batch pattern. */ @Processor(name = "heavyAfterGroupEmitter", family = "ProcessorBufferingTest") public static class HeavyAfterGroupEmitterProcessor implements Serializable { @@ -324,25 +344,63 @@ public void onElement(@Input final Record input) { } @AfterGroup - public void afterGroup(@Output("MAIN") final OutputEmitter main, - @Output("REJECT") final OutputEmitter reject, + public void afterGroup(@Output(value = "MAIN", iterator = true) final OutputIterator main, + @Output(value = "REJECT", iterator = true) final OutputIterator reject, @LastGroup final boolean lastGroup) { if (!lastGroup) { return; } - for (int i = 0; i < RECORD_COUNT; i++) { - final Record record = builderFactory.newRecordBuilder() - .withString("id", "result-" + i) - .withString("name", "processed-record-with-payload-" + i) - .withString("data", "bulk-result-data-field-" + i) - .build(); - if (i % 10 == 0) { - reject.emit(record); - } else { - main.emit(record); + // MAIN gets records where index % 10 != 0 + main.setIterator(new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + while (index < RECORD_COUNT && index % 10 == 0) { + index++; + } + return index < RECORD_COUNT; } - } + + @Override + public Record next() { + final Record record = builderFactory.newRecordBuilder() + .withString("id", "result-" + index) + .withString("name", "processed-record-with-payload-" + index) + .withString("data", "bulk-result-data-field-" + index) + .build(); + index++; + return record; + } + }); + + // REJECT gets records where index % 10 == 0 + reject.setIterator(new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + while (index < RECORD_COUNT && index % 10 != 0) { + index++; + } + return index < RECORD_COUNT; + } + + @Override + public Record next() { + final Record record = builderFactory.newRecordBuilder() + .withString("id", "reject-" + index) + .withString("name", "rejected-record-" + index) + .withString("data", "reject-data-" + index) + .build(); + index++; + return record; + } + }); + inputCount = 0; } } From b33e762cd69ab73872c5f3721b6d4c5a7f739fc4 Mon Sep 17 00:00:00 2001 From: wwang Date: Mon, 13 Jul 2026 14:32:55 +0800 Subject: [PATCH 03/21] fix(QTDI-2709): remove verbose iterator attribute --- .../sdk/component/api/processor/Output.java | 2 -- .../component/api/processor/OutputIterator.java | 4 ++-- .../component/runtime/output/ProcessorImpl.java | 16 ++++++++-------- .../component/runtime/visitor/ModelVisitor.java | 10 +++------- .../beam/components/ProcessorBufferingTest.java | 6 +++--- .../component/tools/ComponentValidatorTest.java | 2 +- 6 files changed, 17 insertions(+), 23 deletions(-) diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java index 25748a032d2c1..a6fde9f1e70b2 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java @@ -26,6 +26,4 @@ public @interface Output { String value() default "__default__"; - - boolean iterator() default false; } diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java index 5804dc8e6bf67..af39bd436b025 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java @@ -22,7 +22,7 @@ * instead of pushing them via {@link OutputEmitter#emit(Object)}. * *

- * Used with {@code @Output(iterator = true)} to enable streaming: + * Used with {@code @Output} on an {@code OutputIterator} parameter to enable streaming: * records are produced on-demand during the consumer's drain loop, * rather than buffered entirely in memory. * @@ -34,7 +34,7 @@ * * @ElementListener * public void process(@Input Record input, - * @Output(iterator = true) OutputIterator output) { + * @Output OutputIterator output) { * output.setIterator(myLazyIterator(input)); * } * } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index c69f8b0eef463..708695fc5d0e4 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -151,11 +151,11 @@ public void beforeGroup() { private BiFunction buildProcessParamBuilder(final Parameter parameter) { if (parameter.isAnnotationPresent(Output.class)) { - final Output annotation = parameter.getAnnotation(Output.class); - if (annotation.iterator()) { - return (inputs, outputs) -> (OutputIterator) outputs.create(annotation.value()); + final String name = parameter.getAnnotation(Output.class).value(); + if (OutputIterator.class == parameter.getType()) { + return (inputs, outputs) -> (OutputIterator) outputs.create(name); } - return (inputs, outputs) -> outputs.create(annotation.value()); + return (inputs, outputs) -> outputs.create(name); } final Class parameterType = parameter.getType(); @@ -169,11 +169,11 @@ private Function toOutputParamBuilder(final Parameter par if (parameter.isAnnotationPresent(LastGroup.class)) { return false; } - final Output annotation = parameter.getAnnotation(Output.class); - if (annotation.iterator()) { - return (OutputIterator) outputs.create(annotation.value()); + final String name = parameter.getAnnotation(Output.class).value(); + if (OutputIterator.class == parameter.getType()) { + return (OutputIterator) outputs.create(name); } - return outputs.create(annotation.value()); + return outputs.create(name); }; } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java index 8514226d1db9c..06d9dadadaee2 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java @@ -197,7 +197,7 @@ private void validateProcessor(final Class input) { final List invalidParams = Stream.of(m.getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { throw new IllegalArgumentException( - "@Output parameter must be of type OutputEmitter or OutputIterator (with iterator = true)"); + "@Output parameter must be of type OutputEmitter or OutputIterator"); } }) .filter(p -> !p.isAnnotationPresent(Output.class)) @@ -246,7 +246,7 @@ private void validateProducer(final Class input, final List afterGrou if (!producers.isEmpty() && Stream.of(producers.get(0).getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { throw new IllegalArgumentException( - "@Output parameter must be of type OutputEmitter or OutputIterator (with iterator = true)"); + "@Output parameter must be of type OutputEmitter or OutputIterator"); } }).filter(p -> !p.isAnnotationPresent(Output.class)).count() < 1) { throw new IllegalArgumentException(input + " doesn't have the input parameter on its producer method"); @@ -257,11 +257,7 @@ private boolean validOutputParam(final Parameter p) { if (!(p.getParameterizedType() instanceof ParameterizedType pt)) { return false; } - final Output annotation = p.getAnnotation(Output.class); - if (annotation != null && annotation.iterator()) { - return OutputIterator.class == pt.getRawType(); - } - return OutputEmitter.class == pt.getRawType(); + return OutputEmitter.class == pt.getRawType() || OutputIterator.class == pt.getRawType(); } private Stream> getPartitionMapperMethods(final boolean infinite) { diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java index 062f245d65072..952bb8618c754 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java @@ -300,7 +300,7 @@ public static class HeavyEmitterProcessor implements Serializable { @ElementListener public void onElement(@Input final Record input, - @Output(iterator = true) final OutputIterator output) { + @Output final OutputIterator output) { output.setIterator(new java.util.Iterator<>() { private int index = 0; @@ -344,8 +344,8 @@ public void onElement(@Input final Record input) { } @AfterGroup - public void afterGroup(@Output(value = "MAIN", iterator = true) final OutputIterator main, - @Output(value = "REJECT", iterator = true) final OutputIterator reject, + public void afterGroup(@Output("MAIN") final OutputIterator main, + @Output("REJECT") final OutputIterator reject, @LastGroup final boolean lastGroup) { if (!lastGroup) { return; diff --git a/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java b/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java index d4aa89522267a..65bdc0ea5e49b 100755 --- a/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java +++ b/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java @@ -571,7 +571,7 @@ void testFailureAfterGroup(final ExceptionSpec expectedException) { expectedException .expectMessage( """ - - @Output parameter must be of type OutputEmitter + - @Output parameter must be of type OutputEmitter or OutputIterator - Parameter of AfterGroup method need to be annotated with Output - class org.talend.test.failure.aftergroup.MyComponent5 must have a single @AfterGroup method with @LastGroup parameter"""); } From 87deb2d6a4c770288667cdfedc3b3e334ebd1832 Mon Sep 17 00:00:00 2001 From: wwang Date: Mon, 13 Jul 2026 15:22:22 +0800 Subject: [PATCH 04/21] doc(QTDI-2709): update OutputIterator documentation to clarify runtime support limitations --- .../talend/sdk/component/api/processor/OutputIterator.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java index af39bd436b025..cfd62d32ebfe6 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java @@ -27,6 +27,12 @@ * rather than buffered entirely in memory. * *

+ * Important: This interface is supported only in the Studio DI runtime. + * It is not supported in Beam-based runners (Cloud) where processors typically + * do not use TCK processor patterns. Using {@code OutputIterator} in a Beam + * pipeline will result in a {@code ClassCastException} at runtime. + * + *

* Example usage in a processor: * *


From 9e8138b85a1fd71eb291340d976b892b810b4241 Mon Sep 17 00:00:00 2001
From: wwang 
Date: Mon, 13 Jul 2026 17:20:18 +0800
Subject: [PATCH 05/21] fix(QTDI-2709): refactor variable names for clarity in
 OutputsHandler and ProcessorBufferingTest

---
 .../sdk/component/runtime/di/OutputsHandler.java   | 14 +++++++-------
 .../di/beam/components/ProcessorBufferingTest.java | 12 ++++++------
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java
index 3eb9b1d2e3662..0ee35afe590c3 100644
--- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java
+++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java
@@ -54,8 +54,8 @@ public OutputFactory asOutputFactoryForGuessSchema() {
             if (ref != null && value != null) {
                 if (value instanceof javax.json.JsonValue) {
                     ref.add(jsonb.fromJson(value.toString(), ref.getType()));
-                } else if (value instanceof Record record) {
-                    ref.add(record.getSchema());
+                } else if (value instanceof Record rec) {
+                    ref.add(rec.getSchema());
                 } else if (value instanceof Schema) {
                     ref.add(value);
                 } else {
@@ -70,8 +70,8 @@ private Object convert(final Object value, final BaseIOHandler.IO ref) {
             return null;
         } else if (value instanceof javax.json.JsonValue) {
             return jsonb.fromJson(value.toString(), ref.getType());
-        } else if (value instanceof Record record) {
-            return registry.find(ref.getType()).newInstance(record);
+        } else if (value instanceof Record rec) {
+            return registry.find(ref.getType()).newInstance(rec);
         } else {
             return jsonb.fromJson(jsonb.toJson(value), ref.getType());
         }
@@ -81,7 +81,7 @@ private Object convert(final Object value, final BaseIOHandler.IO ref) {
      * Internal class implementing both OutputEmitter and OutputIterator.
      * Allows ProcessorImpl to cast to OutputIterator when iterator mode is active.
      */
-    private class OutputEmitterWithIterator implements OutputEmitter, OutputIterator {
+    private class OutputEmitterWithIterator implements OutputEmitter, OutputIterator {
 
         private final BaseIOHandler.IO ref;
 
@@ -90,14 +90,14 @@ private class OutputEmitterWithIterator implements OutputEmitter, OutputIterator
         }
 
         @Override
-        public void emit(final Object value) {
+        public void emit(final T value) {
             if (ref != null && value != null) {
                 ref.add(convert(value, ref));
             }
         }
 
         @Override
-        public void setIterator(final Iterator iterator) {
+        public void setIterator(final Iterator iterator) {
             if (ref != null) {
                 ref.setSource(new Iterator() {
 
diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java
index 952bb8618c754..906e1004e274b 100644
--- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java
+++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java
@@ -312,13 +312,13 @@ public boolean hasNext() {
 
                 @Override
                 public Record next() {
-                    final Record record = builderFactory.newRecordBuilder()
+                    final Record rec = builderFactory.newRecordBuilder()
                             .withString("id", "out-" + index)
                             .withString("name", "generated-record-with-some-payload-" + index)
                             .withString("data", "additional-field-to-increase-memory-footprint-" + index)
                             .build();
                     index++;
-                    return record;
+                    return rec;
                 }
             });
         }
@@ -366,13 +366,13 @@ public boolean hasNext() {
 
                 @Override
                 public Record next() {
-                    final Record record = builderFactory.newRecordBuilder()
+                    final Record rec = builderFactory.newRecordBuilder()
                             .withString("id", "result-" + index)
                             .withString("name", "processed-record-with-payload-" + index)
                             .withString("data", "bulk-result-data-field-" + index)
                             .build();
                     index++;
-                    return record;
+                    return rec;
                 }
             });
 
@@ -391,13 +391,13 @@ public boolean hasNext() {
 
                 @Override
                 public Record next() {
-                    final Record record = builderFactory.newRecordBuilder()
+                    final Record rec = builderFactory.newRecordBuilder()
                             .withString("id", "reject-" + index)
                             .withString("name", "rejected-record-" + index)
                             .withString("data", "reject-data-" + index)
                             .build();
                     index++;
-                    return record;
+                    return rec;
                 }
             });
 

From 0c7f73c5c1958c8782334d38e9700db9cf44fea8 Mon Sep 17 00:00:00 2001
From: wwang 
Date: Tue, 14 Jul 2026 11:19:56 +0800
Subject: [PATCH 06/21] fix(QTDI-2709): implement OutputIterator support in
 OutputFactory and OutputsHandler

---
 .../runtime/output/OutputFactory.java         | 15 ++++
 .../runtime/output/ProcessorImpl.java         |  4 +-
 .../component/runtime/di/BaseIOHandler.java   | 17 ++++-
 .../component/runtime/di/OutputsHandler.java  | 73 +++++++++----------
 4 files changed, 66 insertions(+), 43 deletions(-)

diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java
index c3f8b14bf5fc1..b255c29924b25 100644
--- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java
+++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java
@@ -16,8 +16,23 @@
 package org.talend.sdk.component.runtime.output;
 
 import org.talend.sdk.component.api.processor.OutputEmitter;
+import org.talend.sdk.component.api.processor.OutputIterator;
 
 public interface OutputFactory {
 
     OutputEmitter create(String name);
+
+    /**
+     * Creates an {@link OutputIterator} for the given output branch name.
+     * Supported only in the Studio DI runtime. Other runtimes throw
+     * {@link UnsupportedOperationException} by default.
+     *
+     * @param name the output branch name
+     * @return an OutputIterator for lazy record streaming
+     * @throws UnsupportedOperationException if the runtime does not support iterator mode
+     */
+    default OutputIterator createIterator(String name) {
+        throw new UnsupportedOperationException(
+                "OutputIterator is only supported in the Studio DI runtime");
+    }
 }
diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java
index 708695fc5d0e4..38aab8af13635 100644
--- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java
+++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java
@@ -153,7 +153,7 @@ private BiFunction buildProcessParamBuilder
         if (parameter.isAnnotationPresent(Output.class)) {
             final String name = parameter.getAnnotation(Output.class).value();
             if (OutputIterator.class == parameter.getType()) {
-                return (inputs, outputs) -> (OutputIterator) outputs.create(name);
+                return (inputs, outputs) -> outputs.createIterator(name);
             }
             return (inputs, outputs) -> outputs.create(name);
         }
@@ -171,7 +171,7 @@ private Function toOutputParamBuilder(final Parameter par
             }
             final String name = parameter.getAnnotation(Output.class).value();
             if (OutputIterator.class == parameter.getType()) {
-                return (OutputIterator) outputs.create(name);
+                return outputs.createIterator(name);
             }
             return outputs.create(name);
         };
diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java
index 4a5f37b4478f2..a25adff74677e 100644
--- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java
+++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java
@@ -29,7 +29,9 @@
 import org.talend.sdk.component.runtime.record.RecordConverters;
 
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 public abstract class BaseIOHandler {
 
     protected final Jsonb jsonb;
@@ -84,6 +86,19 @@ protected String getActualName(final String name) {
         return "__default__".equals(name) ? "FLOW" : name;
     }
 
+    /**
+     * Represents a single output connection's data holder.
+     * Supports two modes (mutually exclusive per invocation):
+     * 
    + *
  • Push mode (default): records are added via {@link #add(Object)} into the internal queue. + * Used by {@code OutputEmitter.emit()}.
  • + *
  • Pull mode (iterator): a lazy {@link Iterator} source is set via {@link #setSource(Iterator)}. + * Records are produced on-demand during the drain loop ({@link #hasNext()}/{@link #next()}). + * Used by {@code OutputIterator.setIterator()} in the Studio DI runtime.
  • + *
+ * Note: The {@code source} field is only used by {@code OutputsHandler}; {@code InputsHandler} + * uses only the queue-based push mode. + */ @RequiredArgsConstructor static class IO { @@ -133,7 +148,7 @@ private void closeSource() { try { ((AutoCloseable) source).close(); } catch (final Exception e) { - // best effort cleanup + log.debug("Failed to close iterator source", e); } } } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index 0ee35afe590c3..ed5e949f25d53 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -36,9 +36,39 @@ public OutputsHandler(final Jsonb jsonb, final Map, Object> servicesMap } public OutputFactory asOutputFactory() { - return name -> { - final BaseIOHandler.IO ref = connections.get(getActualName(name)); - return new OutputEmitterWithIterator(ref); + return new OutputFactory() { + + @Override + public OutputEmitter create(final String name) { + final BaseIOHandler.IO ref = connections.get(getActualName(name)); + return value -> { + if (ref != null && value != null) { + ref.add(convert(value, ref)); + } + }; + } + + @Override + public OutputIterator createIterator(final String name) { + final BaseIOHandler.IO ref = connections.get(getActualName(name)); + return iterator -> { + if (ref == null) { + return; + } + ref.setSource(new Iterator() { + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Object next() { + return convert(iterator.next(), ref); + } + }); + }; + } }; } @@ -77,41 +107,4 @@ private Object convert(final Object value, final BaseIOHandler.IO ref) { } } - /** - * Internal class implementing both OutputEmitter and OutputIterator. - * Allows ProcessorImpl to cast to OutputIterator when iterator mode is active. - */ - private class OutputEmitterWithIterator implements OutputEmitter, OutputIterator { - - private final BaseIOHandler.IO ref; - - OutputEmitterWithIterator(final BaseIOHandler.IO ref) { - this.ref = ref; - } - - @Override - public void emit(final T value) { - if (ref != null && value != null) { - ref.add(convert(value, ref)); - } - } - - @Override - public void setIterator(final Iterator iterator) { - if (ref != null) { - ref.setSource(new Iterator() { - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public Object next() { - return convert(iterator.next(), ref); - } - }); - } - } - } } From cac943b5decf2f27027baff656988c41fd7bfb06 Mon Sep 17 00:00:00 2001 From: wwang Date: Tue, 14 Jul 2026 11:43:04 +0800 Subject: [PATCH 07/21] fix(QTDI-2709): rename ProcessorBufferingTest package for consistency in component structure --- .../di/{beam/components => studio}/ProcessorBufferingTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/{beam/components => studio}/ProcessorBufferingTest.java (99%) diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java similarity index 99% rename from component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java rename to component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 906e1004e274b..44a118b272776 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/beam/components/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.talend.sdk.component.runtime.di.beam.components; +package org.talend.sdk.component.runtime.di.studio; import static org.junit.jupiter.api.Assertions.assertTrue; From 25264bc0c967543d04c3337ad25b254ac0e3e870 Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 10:11:24 +0800 Subject: [PATCH 08/21] add streaming api --- .../sdk/component/runtime/output/Processor.java | 4 ++++ .../sdk/component/runtime/output/ProcessorImpl.java | 13 +++++++++++++ .../runtime/manager/chain/AutoChunkProcessor.java | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java index 3638e272576c3..66387b0ec9a64 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java @@ -34,5 +34,9 @@ default boolean isLastGroupUsed() { return false; } + default boolean isStreamingMode() { + return false; + } + void onNext(InputFactory input, OutputFactory output); } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index 38aab8af13635..8226cac9b967b 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -95,6 +95,8 @@ public class ProcessorImpl extends LifecycleImpl implements Processor, Delegated private transient Collection records; + private transient Boolean streamingMode; + private Map internalConfiguration; private RecordConverters.MappingMetaRegistry mappings; @@ -261,6 +263,17 @@ public void afterGroup(final OutputFactory output) { } } + @Override + public boolean isStreamingMode() { + if (streamingMode == null) { + streamingMode = Stream + .concat(findMethods(ElementListener.class), findMethods(AfterGroup.class)) + .flatMap(m -> Stream.of(m.getParameters())) + .anyMatch(p -> p.isAnnotationPresent(Output.class) && OutputIterator.class == p.getType()); + } + return streamingMode; + } + @Override public boolean isLastGroupUsed() { AtomicReference hasLastGroup = new AtomicReference<>(false); diff --git a/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java b/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java index e1ec2c379bf46..d2c1498adef08 100644 --- a/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java +++ b/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java @@ -46,6 +46,10 @@ public void onElement(final InputFactory ins, final OutputFactory outs) { } } + public boolean isStreamingMode() { + return processor.isStreamingMode(); + } + public void flush(final OutputFactory outs) { if (processedItemCount > 0) { processor.afterGroup(outs); From 1788396938e3105f5b039b1f1000a60b5c44a920 Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 10:13:21 +0800 Subject: [PATCH 09/21] improve test --- .../di/studio/ProcessorBufferingTest.java | 104 ++++++++++++------ 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 44a118b272776..939d79b33a4ee 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -28,7 +28,8 @@ import javax.json.bind.Jsonb; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.talend.sdk.component.api.processor.AfterGroup; import org.talend.sdk.component.api.processor.BeforeGroup; import org.talend.sdk.component.api.processor.ElementListener; @@ -73,6 +74,12 @@ class ProcessorBufferingTest { // With streaming, memory should stay well under this. static final long MAX_MEMORY_DELTA_MB = 5; + enum OutputMode { + NO_OUTPUT, + ONE_OUTPUT, + TWO_OUTPUT + } + @BeforeAll static void forceManagerInit() { final ComponentManager manager = ComponentManager.instance(); @@ -87,8 +94,9 @@ static void forceManagerInit() { * FAILS on current code: all 100K records buffered → memory spike > threshold. * PASSES after iterator implementation: records stream lazily → memory stays low. */ - @Test - void elementListenerShouldNotBufferAllRecordsInMemory() { + @ParameterizedTest + @EnumSource(OutputMode.class) + void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { final ComponentManager manager = ComponentManager.instance(); final Map, Object> servicesMapper = getServicesMapper(manager); final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); @@ -106,7 +114,9 @@ void elementListenerShouldNotBufferAllRecordsInMemory() { inputsHandler.addConnection("FLOW", row1Struct.class); final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); - outputsHandler.addConnection("FLOW", row1Struct.class); + if(outputMode != OutputMode.NO_OUTPUT) { + outputsHandler.addConnection("FLOW", row1Struct.class); + } final InputFactory inputFactory = inputsHandler.asInputFactory(); final OutputFactory outputFactory = outputsHandler.asOutputFactory(); @@ -133,27 +143,37 @@ void elementListenerShouldNotBufferAllRecordsInMemory() { chunkProcessor.onElement(inputFactory, outputFactory); chunkProcessor.flush(outputFactory); - chunkProcessor.stop(); - // Measure memory AFTER production, BEFORE drain + // Measure memory AFTER production, BEFORE drain — valid for all output modes final long memoryAfterProduction = usedMemoryMB(); final long memoryDelta = memoryAfterProduction - memoryBefore; System.out.println("=== ProcessorBufferingTest: @ElementListener ==="); + System.out.println("Output mode: " + outputMode); System.out.println("Records emitted: " + RECORD_COUNT); System.out.println("Memory before onElement(): " + memoryBefore + " MB"); System.out.println("Memory after onElement(): " + memoryAfterProduction + " MB"); System.out.println("Memory delta: " + memoryDelta + " MB"); System.out.println("Threshold: " + MAX_MEMORY_DELTA_MB + " MB"); - // Drain after onElement — same as Studio generated code + // Drain — same as Studio generated code (inside + outside loop, skipped for NO_OUTPUT) int drainedCount = 0; - while (outputsHandler.hasMoreData()) { - outputsHandler.getValue("FLOW"); - drainedCount++; + if (outputMode != OutputMode.NO_OUTPUT) { + // sim inside of input loop + while (outputsHandler.hasMoreData()) { + outputsHandler.getValue("FLOW"); + drainedCount++; + } + // sim outside of input loop + while (outputsHandler.hasMoreData()) { + outputsHandler.getValue("FLOW"); + drainedCount++; + } } System.out.println("Records drained: " + drainedCount); + chunkProcessor.stop(); + // ASSERTION: memory delta should be under threshold // FAILS on current code (all records buffered → large delta) // PASSES after iterator implementation (lazy streaming → small delta) @@ -168,8 +188,9 @@ void elementListenerShouldNotBufferAllRecordsInMemory() { * FAILS on current code: all records buffered in @AfterGroup. * PASSES after iterator implementation. */ - @Test - void afterGroupShouldNotBufferAllRecordsInMemory() { + @ParameterizedTest + @EnumSource(OutputMode.class) + void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { final ComponentManager manager = ComponentManager.instance(); final Map, Object> servicesMapper = getServicesMapper(manager); final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); @@ -188,8 +209,12 @@ void afterGroupShouldNotBufferAllRecordsInMemory() { inputsHandler.addConnection("FLOW", row1Struct.class); final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); - outputsHandler.addConnection("MAIN", row1Struct.class); - outputsHandler.addConnection("REJECT", row1Struct.class); + if(outputMode != OutputMode.NO_OUTPUT) { + outputsHandler.addConnection("MAIN", row1Struct.class); + if(outputMode == OutputMode.TWO_OUTPUT) { + outputsHandler.addConnection("REJECT", row1Struct.class); + } + } final InputFactory inputFactory = inputsHandler.asInputFactory(); final OutputFactory outputFactory = outputsHandler.asOutputFactory(); @@ -219,19 +244,22 @@ void afterGroupShouldNotBufferAllRecordsInMemory() { chunkProcessor.onElement(inputFactory, outputFactory); - // Drain after each onElement — same as Studio generated code - while (outputsHandler.hasMoreData()) { - if (outputsHandler.getValue("MAIN") != null) { - mainCount++; - } - if (outputsHandler.getValue("REJECT") != null) { - rejectCount++; + if (outputMode != OutputMode.NO_OUTPUT) { + // Drain after each onElement — same as Studio generated code + while (outputsHandler.hasMoreData()) { + if (outputsHandler.getValue("MAIN") != null) { + mainCount++; + } + if (outputMode == OutputMode.TWO_OUTPUT) { + if (outputsHandler.getValue("REJECT") != null) { + rejectCount++; + } + } } } } chunkProcessor.flush(outputFactory); - chunkProcessor.stop(); // GC to reclaim input record objects from the loop System.gc(); @@ -250,21 +278,27 @@ void afterGroupShouldNotBufferAllRecordsInMemory() { System.out.println("Memory after @AfterGroup: " + memoryAfterGroup + " MB"); System.out.println("Memory delta: " + memoryDelta + " MB"); - while (outputsHandler.hasMoreData()) { - if (outputsHandler.getValue("MAIN") != null) { - mainCount++; - } - if (outputsHandler.getValue("REJECT") != null) { - rejectCount++; + if (outputMode != OutputMode.NO_OUTPUT) { + while (outputsHandler.hasMoreData()) { + if (outputsHandler.getValue("MAIN") != null) { + mainCount++; + } + if (outputMode == OutputMode.TWO_OUTPUT) { + if (outputsHandler.getValue("REJECT") != null) { + rejectCount++; + } + } } + + // Verify all records were produced + assertTrue(mainCount + rejectCount > 0, + "Should have produced records in @AfterGroup"); } System.out.println("MAIN drained: " + mainCount); System.out.println("REJECT drained: " + rejectCount); - // Verify all records were produced - assertTrue(mainCount + rejectCount > 0, - "Should have produced records in @AfterGroup"); + chunkProcessor.stop(); // ASSERTION: memory delta should be under threshold // FAILS on current code (all records buffered → large delta) @@ -351,14 +385,14 @@ public void afterGroup(@Output("MAIN") final OutputIterator main, return; } - // MAIN gets records where index % 10 != 0 + // MAIN gets records where index % 2 != 0 main.setIterator(new java.util.Iterator<>() { private int index = 0; @Override public boolean hasNext() { - while (index < RECORD_COUNT && index % 10 == 0) { + while (index < RECORD_COUNT && index % 2 == 0) { index++; } return index < RECORD_COUNT; @@ -376,14 +410,14 @@ public Record next() { } }); - // REJECT gets records where index % 10 == 0 + // REJECT gets records where index % 2 == 0 reject.setIterator(new java.util.Iterator<>() { private int index = 0; @Override public boolean hasNext() { - while (index < RECORD_COUNT && index % 10 != 0) { + while (index < RECORD_COUNT && index % 2 != 0) { index++; } return index < RECORD_COUNT; From 283bd80331e69a2ccd7395dff04fbfd5d3a0a6a9 Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 10:13:30 +0800 Subject: [PATCH 10/21] Revert "add streaming api" This reverts commit 25264bc0c967543d04c3337ad25b254ac0e3e870. --- .../sdk/component/runtime/output/Processor.java | 4 ---- .../sdk/component/runtime/output/ProcessorImpl.java | 13 ------------- .../runtime/manager/chain/AutoChunkProcessor.java | 4 ---- 3 files changed, 21 deletions(-) diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java index 66387b0ec9a64..3638e272576c3 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/Processor.java @@ -34,9 +34,5 @@ default boolean isLastGroupUsed() { return false; } - default boolean isStreamingMode() { - return false; - } - void onNext(InputFactory input, OutputFactory output); } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index 8226cac9b967b..38aab8af13635 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -95,8 +95,6 @@ public class ProcessorImpl extends LifecycleImpl implements Processor, Delegated private transient Collection records; - private transient Boolean streamingMode; - private Map internalConfiguration; private RecordConverters.MappingMetaRegistry mappings; @@ -263,17 +261,6 @@ public void afterGroup(final OutputFactory output) { } } - @Override - public boolean isStreamingMode() { - if (streamingMode == null) { - streamingMode = Stream - .concat(findMethods(ElementListener.class), findMethods(AfterGroup.class)) - .flatMap(m -> Stream.of(m.getParameters())) - .anyMatch(p -> p.isAnnotationPresent(Output.class) && OutputIterator.class == p.getType()); - } - return streamingMode; - } - @Override public boolean isLastGroupUsed() { AtomicReference hasLastGroup = new AtomicReference<>(false); diff --git a/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java b/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java index d2c1498adef08..e1ec2c379bf46 100644 --- a/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java +++ b/component-runtime-manager/src/main/java/org/talend/sdk/component/runtime/manager/chain/AutoChunkProcessor.java @@ -46,10 +46,6 @@ public void onElement(final InputFactory ins, final OutputFactory outs) { } } - public boolean isStreamingMode() { - return processor.isStreamingMode(); - } - public void flush(final OutputFactory outs) { if (processedItemCount > 0) { processor.afterGroup(outs); From d7273b4a55f50b5f16beffd2d311c3292e534d90 Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 10:29:44 +0800 Subject: [PATCH 11/21] improve test --- .../di/studio/ProcessorBufferingTest.java | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 939d79b33a4ee..0bf158f27c36b 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -114,7 +114,7 @@ void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { inputsHandler.addConnection("FLOW", row1Struct.class); final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); - if(outputMode != OutputMode.NO_OUTPUT) { + if (outputMode != OutputMode.NO_OUTPUT) { outputsHandler.addConnection("FLOW", row1Struct.class); } @@ -131,19 +131,28 @@ void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { inputsHandler.setInputValue("FLOW", inputRow); // Force GC to get a clean baseline - System.gc(); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + gc(); + final long memoryBefore = usedMemoryMB(); // --- Producer emits all records in onElement() --- chunkProcessor.onElement(inputFactory, outputFactory); + // Drain — same as Studio generated code (inside + outside loop, skipped for NO_OUTPUT) + int drainedCount = 0; + if (outputMode != OutputMode.NO_OUTPUT) { + // sim inside of input loop + while (outputsHandler.hasMoreData()) { + outputsHandler.getValue("FLOW"); + drainedCount++; + } + } + chunkProcessor.flush(outputFactory); + // GC to reclaim input record objects from the loop + gc(); + // Measure memory AFTER production, BEFORE drain — valid for all output modes final long memoryAfterProduction = usedMemoryMB(); final long memoryDelta = memoryAfterProduction - memoryBefore; @@ -156,14 +165,7 @@ void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { System.out.println("Memory delta: " + memoryDelta + " MB"); System.out.println("Threshold: " + MAX_MEMORY_DELTA_MB + " MB"); - // Drain — same as Studio generated code (inside + outside loop, skipped for NO_OUTPUT) - int drainedCount = 0; if (outputMode != OutputMode.NO_OUTPUT) { - // sim inside of input loop - while (outputsHandler.hasMoreData()) { - outputsHandler.getValue("FLOW"); - drainedCount++; - } // sim outside of input loop while (outputsHandler.hasMoreData()) { outputsHandler.getValue("FLOW"); @@ -209,9 +211,9 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { inputsHandler.addConnection("FLOW", row1Struct.class); final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); - if(outputMode != OutputMode.NO_OUTPUT) { + if (outputMode != OutputMode.NO_OUTPUT) { outputsHandler.addConnection("MAIN", row1Struct.class); - if(outputMode == OutputMode.TWO_OUTPUT) { + if (outputMode == OutputMode.TWO_OUTPUT) { outputsHandler.addConnection("REJECT", row1Struct.class); } } @@ -225,15 +227,9 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { int rejectCount = 0; // Force GC to get a clean baseline - System.gc(); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + gc(); final long memoryBefore = usedMemoryMB(); - // Feed 5 inputs (triggers @AfterGroup) for (int i = 0; i < RECORD_COUNT; i++) { final Record inputRecord = builderFactory.newRecordBuilder() .withString("id", "input-" + i) @@ -262,12 +258,7 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { chunkProcessor.flush(outputFactory); // GC to reclaim input record objects from the loop - System.gc(); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + gc(); final long memoryAfterGroup = usedMemoryMB(); final long memoryDelta = memoryAfterGroup - memoryBefore; @@ -461,4 +452,14 @@ public void readData(final ObjectInputStream objectInputStream) { throw new UnsupportedOperationException("#readData()"); } } + + private void gc() { + // GC to reclaim input record objects from the loop + System.gc(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } From d2124c62cdc45a3a1477381d9047b873ccf026a7 Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 17:27:03 +0800 Subject: [PATCH 12/21] feat: implement MultiOutputIterator for split streaming support --- .../api/processor/MultiOutputIterator.java | 100 +++++++++++++++ .../component/api/processor/TaggedOutput.java | 52 ++++++++ .../runtime/output/OutputFactory.java | 19 +++ .../runtime/output/ProcessorImpl.java | 7 + .../runtime/visitor/ModelVisitor.java | 9 +- .../component/runtime/di/BaseIOHandler.java | 86 +++++++++++++ .../component/runtime/di/OutputsHandler.java | 24 ++++ .../di/studio/ProcessorBufferingTest.java | 120 ++++++++++++++++++ 8 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java create mode 100644 component-api/src/main/java/org/talend/sdk/component/api/processor/TaggedOutput.java diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java new file mode 100644 index 0000000000000..2f9d72166cc90 --- /dev/null +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java @@ -0,0 +1,100 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.api.processor; + +import java.util.Iterator; + +/** + * Allows a processor to route records from a single lazy iterator to multiple + * output connections, enabling true streaming split without buffering. + * + *

+ * Unlike using separate {@link OutputIterator}s per output (which are consumed + * in lockstep by the drain loop), a {@code MultiOutputIterator} uses a single + * source iterator where each element is tagged with its target output name via + * {@link TaggedOutput}. The Studio DI runtime reads one tagged record at a time + * and routes it to the appropriate connection — enabling correct split behavior + * when different records go to different outputs. + * + *

+ * Important: This interface is supported only in the Studio DI runtime. + * + *

+ * Example usage (split in @ElementListener): + * + *

+ * {@code
+ * 
+ * @ElementListener
+ * public void process(@Input Record input,
+ *         @Output MultiOutputIterator splitter) {
+ *     splitter.setIterator(myLazySplitIterator(input));
+ * }
+ * }
+ * 
+ * + *

+ * Example usage (bulk split in @AfterGroup): + * + *

+ * {@code
+ * 
+ * @AfterGroup
+ * public void afterGroup(@Output MultiOutputIterator splitter) {
+ *     splitter.setIterator(
+ *             records.stream()
+ *                     .map(r -> isValid(r)
+ *                             ? TaggedOutput.of("MAIN", transform(r))
+ *                             : TaggedOutput.of("REJECT", r))
+ *                     .iterator());
+ * }
+ * }
+ * 
+ * + *

+ * The drain loop on the Studio side should use per-connection + * {@code hasMoreData(name)} checks to efficiently consume only the records + * belonging to each output: + * + *

+ * {@code
+ * while (outputsHandler.hasMoreData()) {
+ *     if (outputsHandler.hasMoreData("MAIN")) {
+ *         mainRow = outputsHandler.getValue("MAIN");
+ *         // ... process mainRow
+ *     }
+ *     if (outputsHandler.hasMoreData("REJECT")) {
+ *         rejectRow = outputsHandler.getValue("REJECT");
+ *         // ... process rejectRow
+ *     }
+ * }
+ * }
+ * 
+ * + * @param the record type + * @see TaggedOutput + * @see OutputIterator + */ +public interface MultiOutputIterator { + + /** + * Sets the lazy iterator that produces tagged output records on demand. + * Each {@link TaggedOutput} specifies which output connection receives the record. + * + * @param iterator the iterator producing tagged records + */ + void setIterator(Iterator> iterator); +} diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/TaggedOutput.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/TaggedOutput.java new file mode 100644 index 0000000000000..a6bf6fb5c1d80 --- /dev/null +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/TaggedOutput.java @@ -0,0 +1,52 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.api.processor; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * A record tagged with its target output connection name. + * Used with {@link MultiOutputIterator} to route individual records to + * specific output connections from a single streaming iterator. + * + * @param the record type + */ +@Getter +@RequiredArgsConstructor +public class TaggedOutput { + + /** + * The name of the output connection this record should be routed to. + * Use {@code "__default__"} or {@code "FLOW"} for the default output. + */ + private final String outputName; + + /** The record to emit to the named output. */ + private final T record; + + /** + * Convenience factory method. + * + * @param outputName the target output connection name + * @param record the record to emit + * @param the record type + * @return a new TaggedOutput + */ + public static TaggedOutput of(final String outputName, final T record) { + return new TaggedOutput<>(outputName, record); + } +} diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java index b255c29924b25..ed4d7595e2fcd 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java @@ -15,6 +15,7 @@ */ package org.talend.sdk.component.runtime.output; +import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.OutputEmitter; import org.talend.sdk.component.api.processor.OutputIterator; @@ -35,4 +36,22 @@ default OutputIterator createIterator(String name) { throw new UnsupportedOperationException( "OutputIterator is only supported in the Studio DI runtime"); } + + /** + * Creates a {@link MultiOutputIterator} that routes tagged records to multiple + * output connections from a single lazy iterator. + * + *

+ * Supported only in the Studio DI runtime. Use this instead of multiple + * {@link OutputIterator}s when records must be split across outputs from a + * shared source without buffering. + * + * @param the record type + * @return a MultiOutputIterator for tagged split streaming + * @throws UnsupportedOperationException if the runtime does not support multi-output iterator mode + */ + default MultiOutputIterator createMultiOutputIterator() { + throw new UnsupportedOperationException( + "MultiOutputIterator is only supported in the Studio DI runtime"); + } } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index 38aab8af13635..a22984e78c6cd 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -55,6 +55,7 @@ import org.talend.sdk.component.api.processor.ElementListener; import org.talend.sdk.component.api.processor.Input; import org.talend.sdk.component.api.processor.LastGroup; +import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.Output; import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; @@ -155,6 +156,9 @@ private BiFunction buildProcessParamBuilder if (OutputIterator.class == parameter.getType()) { return (inputs, outputs) -> outputs.createIterator(name); } + if (MultiOutputIterator.class == parameter.getType()) { + return (inputs, outputs) -> outputs.createMultiOutputIterator(); + } return (inputs, outputs) -> outputs.create(name); } @@ -173,6 +177,9 @@ private Function toOutputParamBuilder(final Parameter par if (OutputIterator.class == parameter.getType()) { return outputs.createIterator(name); } + if (MultiOutputIterator.class == parameter.getType()) { + return outputs.createMultiOutputIterator(); + } return outputs.create(name); }; } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java index 06d9dadadaee2..ba12827c9852b 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java @@ -44,6 +44,7 @@ import org.talend.sdk.component.api.processor.BeforeGroup; import org.talend.sdk.component.api.processor.ElementListener; import org.talend.sdk.component.api.processor.LastGroup; +import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.Output; import org.talend.sdk.component.api.processor.OutputEmitter; import org.talend.sdk.component.api.processor.OutputIterator; @@ -197,7 +198,7 @@ private void validateProcessor(final Class input) { final List invalidParams = Stream.of(m.getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { throw new IllegalArgumentException( - "@Output parameter must be of type OutputEmitter or OutputIterator"); + "@Output parameter must be of type OutputEmitter, OutputIterator, or MultiOutputIterator"); } }) .filter(p -> !p.isAnnotationPresent(Output.class)) @@ -246,7 +247,7 @@ private void validateProducer(final Class input, final List afterGrou if (!producers.isEmpty() && Stream.of(producers.get(0).getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { throw new IllegalArgumentException( - "@Output parameter must be of type OutputEmitter or OutputIterator"); + "@Output parameter must be of type OutputEmitter, OutputIterator, or MultiOutputIterator"); } }).filter(p -> !p.isAnnotationPresent(Output.class)).count() < 1) { throw new IllegalArgumentException(input + " doesn't have the input parameter on its producer method"); @@ -257,7 +258,9 @@ private boolean validOutputParam(final Parameter p) { if (!(p.getParameterizedType() instanceof ParameterizedType pt)) { return false; } - return OutputEmitter.class == pt.getRawType() || OutputIterator.class == pt.getRawType(); + return OutputEmitter.class == pt.getRawType() + || OutputIterator.class == pt.getRawType() + || MultiOutputIterator.class == pt.getRawType(); } private Stream> getPartitionMapperMethods(final boolean infinite) { diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index a25adff74677e..dd003284ed419 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -25,6 +25,7 @@ import javax.json.bind.Jsonb; +import org.talend.sdk.component.api.processor.TaggedOutput; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; import org.talend.sdk.component.runtime.record.RecordConverters; @@ -42,6 +43,19 @@ public abstract class BaseIOHandler { protected final Map connections = new TreeMap<>(); + /** + * Single tagged source iterator for split streaming. + * Set via {@link #setTaggedSource(Iterator)}. + * When non-null, {@link #hasMoreData()} and {@link #getValue(String)} operate in tagged mode. + */ + private Iterator> taggedSource; + + /** + * One-record look-ahead buffer for the tagged source. + * Populated by {@link #hasMoreData(String)} when peeking ahead. + */ + private TaggedOutput pendingTaggedOutput; + public BaseIOHandler(final Jsonb jsonb, final Map, Object> servicesMapper) { this.jsonb = jsonb; this.recordBuilderMapper = (RecordBuilderFactory) servicesMapper.get(RecordBuilderFactory.class); @@ -72,16 +86,88 @@ public void addConnection(final String connectorName, final Class type) { public void reset() { connections.values().forEach(IO::reset); + taggedSource = null; + pendingTaggedOutput = null; } public T getValue(final String connectorName) { + if (taggedSource != null) { + if (pendingTaggedOutput != null + && pendingTaggedOutput.getOutputName().equals(connectorName)) { + final T value = (T) pendingTaggedOutput.getRecord(); + pendingTaggedOutput = null; + return value; + } + return null; + } return (T) connections.get(connectorName).next(); } public boolean hasMoreData() { + if (taggedSource != null) { + return pendingTaggedOutput != null || taggedSource.hasNext(); + } return connections.entrySet().stream().anyMatch(e -> e.getValue().hasNext()); } + /** + * Returns {@code true} if the named connection has a record ready to be consumed. + * + *

+ * In tagged-source mode (when the component used a + * {@link org.talend.sdk.component.api.processor.MultiOutputIterator}), + * this peeks one record ahead from the shared tagged source and returns whether it + * belongs to {@code connectorName}. The peeked record is buffered and consumed by + * the next {@link #getValue(String)} call for the same connection. + * + *

+ * In independent-iterator mode, this directly checks the per-connection iterator/queue. + * + *

+ * Use this method in the Studio drain loop to avoid calling {@link #getValue(String)} + * on connections that have no data: + * + *

{@code
+     * while (outputsHandler.hasMoreData()) {
+     *     if (outputsHandler.hasMoreData("MAIN")) {
+     *         mainRow = outputsHandler.getValue("MAIN");
+     *     }
+     *     if (outputsHandler.hasMoreData("REJECT")) {
+     *         rejectRow = outputsHandler.getValue("REJECT");
+     *     }
+     * }
+     * }
+ * + * @param connectorName the output connection name to check + * @return {@code true} if a record for this connection is available + */ + public boolean hasMoreData(final String connectorName) { + if (taggedSource != null) { + if (pendingTaggedOutput != null) { + return pendingTaggedOutput.getOutputName().equals(connectorName); + } + if (taggedSource.hasNext()) { + pendingTaggedOutput = taggedSource.next(); + return pendingTaggedOutput.getOutputName().equals(connectorName); + } + return false; + } + final IO io = connections.get(connectorName); + return io != null && io.hasNext(); + } + + /** + * Sets a shared tagged-source iterator for split streaming across multiple outputs. + * Calling this switches the handler to tagged mode; subsequent calls to + * {@link #hasMoreData(String)} and {@link #getValue(String)} operate on the tagged source. + * + * @param source the tagged iterator produced by a MultiOutputIterator component + */ + protected void setTaggedSource(final Iterator> source) { + this.taggedSource = source; + this.pendingTaggedOutput = null; + } + protected String getActualName(final String name) { return "__default__".equals(name) ? "FLOW" : name; } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index ed5e949f25d53..c127b90ab69ef 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -20,8 +20,10 @@ import javax.json.bind.Jsonb; +import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.OutputEmitter; import org.talend.sdk.component.api.processor.OutputIterator; +import org.talend.sdk.component.api.processor.TaggedOutput; import org.talend.sdk.component.api.record.Record; import org.talend.sdk.component.api.record.Schema; import org.talend.sdk.component.runtime.output.OutputFactory; @@ -69,6 +71,28 @@ public Object next() { }); }; } + + @Override + public MultiOutputIterator createMultiOutputIterator() { + return iterator -> setTaggedSource(new Iterator>() { + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public TaggedOutput next() { + final TaggedOutput tagged = iterator.next(); + final String name = getActualName(tagged.getOutputName()); + final BaseIOHandler.IO ref = connections.get(name); + if (ref == null || tagged.getRecord() == null) { + return tagged; + } + return TaggedOutput.of(name, convert(tagged.getRecord(), ref)); + } + }); + } }; } diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 0bf158f27c36b..a22ba6e0cc94a 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -35,9 +35,11 @@ import org.talend.sdk.component.api.processor.ElementListener; import org.talend.sdk.component.api.processor.Input; import org.talend.sdk.component.api.processor.LastGroup; +import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.Output; import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.processor.Processor; +import org.talend.sdk.component.api.processor.TaggedOutput; import org.talend.sdk.component.api.record.Record; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; import org.talend.sdk.component.runtime.di.AutoChunkProcessor; @@ -314,6 +316,89 @@ private Map, Object> getServicesMapper(ComponentManager manager) { .getServices(); } + /** + * Tests that a processor using {@link MultiOutputIterator} correctly splits records + * between MAIN and REJECT without buffering and without requiring lockstep iteration. + * + *

+ * The split processor routes even-indexed records to MAIN and odd-indexed to REJECT + * from a SINGLE shared source iterator. Without tagged-output support this would + * require buffering all records first. + */ + @org.junit.jupiter.api.Test + void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { + final ComponentManager manager = ComponentManager.instance(); + final Map, Object> servicesMapper = getServicesMapper(manager); + final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); + builderFactory = (RecordBuilderFactory) servicesMapper.get(RecordBuilderFactory.class); + + final org.talend.sdk.component.runtime.output.Processor processor = manager + .findProcessor("ProcessorBufferingTest", "splitEmitter", 1, new HashMap<>()) + .orElseThrow(() -> new IllegalStateException("splitEmitter processor not found")); + JobStateAware.init(processor, new HashMap<>()); + + final AutoChunkProcessor chunkProcessor = new AutoChunkProcessor(RECORD_COUNT + 1, processor); + chunkProcessor.start(); + + final InputsHandler inputsHandler = new InputsHandler(jsonb, servicesMapper); + inputsHandler.addConnection("FLOW", row1Struct.class); + + final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); + outputsHandler.addConnection("MAIN", row1Struct.class); + outputsHandler.addConnection("REJECT", row1Struct.class); + + final InputFactory inputFactory = inputsHandler.asInputFactory(); + final OutputFactory outputFactory = outputsHandler.asOutputFactory(); + + final Record inputRecord = builderFactory.newRecordBuilder() + .withString("id", "input-1") + .withString("name", "trigger") + .build(); + final RecordConverters.MappingMetaRegistry registry = new RecordConverters.MappingMetaRegistry(); + final row1Struct inputRow = (row1Struct) registry.find(row1Struct.class).newInstance(inputRecord); + inputsHandler.setInputValue("FLOW", inputRow); + + gc(); + final long memoryBefore = usedMemoryMB(); + + chunkProcessor.onElement(inputFactory, outputFactory); + + // Drain using per-connection hasMoreData checks — correct for split streaming + int mainCount = 0; + int rejectCount = 0; + while (outputsHandler.hasMoreData()) { + if (outputsHandler.hasMoreData("MAIN")) { + outputsHandler.getValue("MAIN"); + mainCount++; + } + if (outputsHandler.hasMoreData("REJECT")) { + outputsHandler.getValue("REJECT"); + rejectCount++; + } + } + + chunkProcessor.flush(outputFactory); + gc(); + final long memoryDelta = usedMemoryMB() - memoryBefore; + + System.out.println("=== ProcessorBufferingTest: MultiOutputIterator split ==="); + System.out.println("Records emitted: " + RECORD_COUNT); + System.out.println("MAIN drained: " + mainCount); + System.out.println("REJECT drained: " + rejectCount); + System.out.println("Memory delta: " + memoryDelta + " MB"); + + // Split should route exactly half to each + final int expected = RECORD_COUNT / 2; + org.junit.jupiter.api.Assertions.assertEquals(expected, mainCount, + "MAIN should receive half the records"); + org.junit.jupiter.api.Assertions.assertEquals(expected, rejectCount, + "REJECT should receive the other half"); + org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); + + chunkProcessor.stop(); + } + // --- Test components --- /** @@ -453,6 +538,41 @@ public void readData(final ObjectInputStream objectInputStream) { } } + /** + * Processor that uses a single {@link MultiOutputIterator} to split 100K records + * between MAIN (even indices) and REJECT (odd indices) from a single lazy source. + * This is the true-split scenario: one source, two outputs, no buffering. + */ + @Processor(name = "splitEmitter", family = "ProcessorBufferingTest") + public static class SplitEmitterProcessor implements Serializable { + + @ElementListener + public void onElement(@Input final Record input, + @Output final MultiOutputIterator splitter) { + splitter.setIterator(new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + return index < RECORD_COUNT; + } + + @Override + public TaggedOutput next() { + final String outputName = (index % 2 == 0) ? "MAIN" : "REJECT"; + final Record rec = builderFactory.newRecordBuilder() + .withString("id", outputName.toLowerCase() + "-" + index) + .withString("name", "split-record-" + index) + .withString("data", "split-data-" + index) + .build(); + index++; + return TaggedOutput.of(outputName, rec); + } + }); + } + } + private void gc() { // GC to reclaim input record objects from the loop System.gc(); From 386bb57396b1b6f60a9702e692a7b682e885a96f Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 17:38:34 +0800 Subject: [PATCH 13/21] refactor: update tagged source handling for split streaming support --- .../api/processor/MultiOutputIterator.java | 12 +-- .../component/runtime/di/BaseIOHandler.java | 99 ++++++++++++------- .../component/runtime/di/OutputsHandler.java | 24 ++--- .../di/studio/ProcessorBufferingTest.java | 6 +- 4 files changed, 75 insertions(+), 66 deletions(-) diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java index 2f9d72166cc90..c436c34f7e810 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java @@ -66,20 +66,14 @@ * *

* The drain loop on the Studio side should use per-connection - * {@code hasMoreData(name)} checks to efficiently consume only the records + * {@code hasDataFor(name)} checks to efficiently consume only the records * belonging to each output: * *

  * {@code
  * while (outputsHandler.hasMoreData()) {
- *     if (outputsHandler.hasMoreData("MAIN")) {
- *         mainRow = outputsHandler.getValue("MAIN");
- *         // ... process mainRow
- *     }
- *     if (outputsHandler.hasMoreData("REJECT")) {
- *         rejectRow = outputsHandler.getValue("REJECT");
- *         // ... process rejectRow
- *     }
+ *     if (outputsHandler.hasDataFor("MAIN"))   mainRow   = outputsHandler.getValue("MAIN");
+ *     if (outputsHandler.hasDataFor("REJECT"))  rejectRow = outputsHandler.getValue("REJECT");
  * }
  * }
  * 
diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index dd003284ed419..b45162dc9f0f0 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -25,7 +25,6 @@ import javax.json.bind.Jsonb; -import org.talend.sdk.component.api.processor.TaggedOutput; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; import org.talend.sdk.component.runtime.record.RecordConverters; @@ -44,17 +43,29 @@ public abstract class BaseIOHandler { protected final Map connections = new TreeMap<>(); /** - * Single tagged source iterator for split streaming. - * Set via {@link #setTaggedSource(Iterator)}. - * When non-null, {@link #hasMoreData()} and {@link #getValue(String)} operate in tagged mode. + * Functional interface used internally to advance the tagged source one step. + * Implementations call {@link #setPending(String, Object)} to store the next + * record's output name and converted value, then return {@code true}. + * Return {@code false} when the source is exhausted. */ - private Iterator> taggedSource; + @FunctionalInterface + protected interface TaggedAdvancer { + + boolean advance(); + } /** - * One-record look-ahead buffer for the tagged source. - * Populated by {@link #hasMoreData(String)} when peeking ahead. + * Tagged-source advancer for split streaming. + * Non-null only when the component used a + * {@link org.talend.sdk.component.api.processor.MultiOutputIterator}. */ - private TaggedOutput pendingTaggedOutput; + private TaggedAdvancer taggedSource; + + /** Output-connection name of the look-ahead record; {@code null} when no record is pending. */ + private String pendingOutputName; + + /** Converted record value paired with {@link #pendingOutputName}. */ + private Object pendingRecord; public BaseIOHandler(final Jsonb jsonb, final Map, Object> servicesMapper) { this.jsonb = jsonb; @@ -87,15 +98,16 @@ public void addConnection(final String connectorName, final Class type) { public void reset() { connections.values().forEach(IO::reset); taggedSource = null; - pendingTaggedOutput = null; + pendingOutputName = null; + pendingRecord = null; } public T getValue(final String connectorName) { if (taggedSource != null) { - if (pendingTaggedOutput != null - && pendingTaggedOutput.getOutputName().equals(connectorName)) { - final T value = (T) pendingTaggedOutput.getRecord(); - pendingTaggedOutput = null; + if (connectorName.equals(pendingOutputName)) { + final T value = (T) pendingRecord; + pendingOutputName = null; + pendingRecord = null; return value; } return null; @@ -105,7 +117,7 @@ public T getValue(final String connectorName) { public boolean hasMoreData() { if (taggedSource != null) { - return pendingTaggedOutput != null || taggedSource.hasNext(); + return pendingOutputName != null || taggedSource.advance(); } return connections.entrySet().stream().anyMatch(e -> e.getValue().hasNext()); } @@ -117,7 +129,7 @@ public boolean hasMoreData() { * In tagged-source mode (when the component used a * {@link org.talend.sdk.component.api.processor.MultiOutputIterator}), * this peeks one record ahead from the shared tagged source and returns whether it - * belongs to {@code connectorName}. The peeked record is buffered and consumed by + * belongs to {@code connectionName}. The peeked record is buffered and consumed by * the next {@link #getValue(String)} call for the same connection. * *

@@ -126,46 +138,57 @@ public boolean hasMoreData() { *

* Use this method in the Studio drain loop to avoid calling {@link #getValue(String)} * on connections that have no data: - * + * *

{@code
      * while (outputsHandler.hasMoreData()) {
-     *     if (outputsHandler.hasMoreData("MAIN")) {
+     *     if (outputsHandler.hasDataFor("MAIN"))
      *         mainRow = outputsHandler.getValue("MAIN");
-     *     }
-     *     if (outputsHandler.hasMoreData("REJECT")) {
+     *     if (outputsHandler.hasDataFor("REJECT"))
      *         rejectRow = outputsHandler.getValue("REJECT");
-     *     }
      * }
      * }
* - * @param connectorName the output connection name to check - * @return {@code true} if a record for this connection is available + * @param connectionName the output connection name to check + * @return {@code true} if a record for this connection is immediately available */ - public boolean hasMoreData(final String connectorName) { + public boolean hasDataFor(final String connectionName) { if (taggedSource != null) { - if (pendingTaggedOutput != null) { - return pendingTaggedOutput.getOutputName().equals(connectorName); - } - if (taggedSource.hasNext()) { - pendingTaggedOutput = taggedSource.next(); - return pendingTaggedOutput.getOutputName().equals(connectorName); + if (pendingOutputName != null) { + return pendingOutputName.equals(connectionName); } - return false; + return taggedSource.advance() && pendingOutputName.equals(connectionName); } - final IO io = connections.get(connectorName); + final IO io = connections.get(connectionName); return io != null && io.hasNext(); } /** - * Sets a shared tagged-source iterator for split streaming across multiple outputs. - * Calling this switches the handler to tagged mode; subsequent calls to - * {@link #hasMoreData(String)} and {@link #getValue(String)} operate on the tagged source. + * Sets a tagged-source advancer for split streaming across multiple outputs. + * Switching to tagged mode — subsequent {@link #hasMoreData()}, {@link #hasDataFor(String)}, + * and {@link #getValue(String)} operate via the advancer instead of per-connection queues. + * + *

+ * The {@code advancer} implementation must call {@link #setPending(String, Object)} with + * the next output-connection name and converted record value, then return {@code true}. + * Return {@code false} when the source is exhausted. + * + * @param advancer the tagged-source advancer produced by a MultiOutputIterator setup + */ + protected void setTaggedSource(final TaggedAdvancer advancer) { + this.taggedSource = advancer; + this.pendingOutputName = null; + this.pendingRecord = null; + } + + /** + * Called by a {@link TaggedAdvancer} to store the next pending record. * - * @param source the tagged iterator produced by a MultiOutputIterator component + * @param outputName the output connection name for the record + * @param record the converted record value */ - protected void setTaggedSource(final Iterator> source) { - this.taggedSource = source; - this.pendingTaggedOutput = null; + protected void setPending(final String outputName, final Object record) { + this.pendingOutputName = outputName; + this.pendingRecord = record; } protected String getActualName(final String name) { diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index c127b90ab69ef..c564cffe89f1d 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -74,23 +74,15 @@ public Object next() { @Override public MultiOutputIterator createMultiOutputIterator() { - return iterator -> setTaggedSource(new Iterator>() { - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public TaggedOutput next() { - final TaggedOutput tagged = iterator.next(); - final String name = getActualName(tagged.getOutputName()); - final BaseIOHandler.IO ref = connections.get(name); - if (ref == null || tagged.getRecord() == null) { - return tagged; - } - return TaggedOutput.of(name, convert(tagged.getRecord(), ref)); + return iterator -> setTaggedSource(() -> { + if (!iterator.hasNext()) { + return false; } + final TaggedOutput tagged = iterator.next(); + final String name = getActualName(tagged.getOutputName()); + final BaseIOHandler.IO ref = connections.get(name); + setPending(name, ref != null ? convert(tagged.getRecord(), ref) : tagged.getRecord()); + return true; }); } }; diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index a22ba6e0cc94a..96e5744d23cde 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -363,15 +363,15 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { chunkProcessor.onElement(inputFactory, outputFactory); - // Drain using per-connection hasMoreData checks — correct for split streaming + // Drain using per-connection hasDataFor checks — correct for split streaming int mainCount = 0; int rejectCount = 0; while (outputsHandler.hasMoreData()) { - if (outputsHandler.hasMoreData("MAIN")) { + if (outputsHandler.hasDataFor("MAIN")) { outputsHandler.getValue("MAIN"); mainCount++; } - if (outputsHandler.hasMoreData("REJECT")) { + if (outputsHandler.hasDataFor("REJECT")) { outputsHandler.getValue("REJECT"); rejectCount++; } From d54c03f9dfd7ea9f78ae88f62af16c850d5a08fe Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 17:48:49 +0800 Subject: [PATCH 14/21] feat: enhance MultiOutputIterator with independent mode for separate lazy sources --- .../api/processor/MultiOutputIterator.java | 78 +++++++---- .../component/runtime/di/OutputsHandler.java | 46 +++++-- .../di/studio/ProcessorBufferingTest.java | 126 ++++++++++++++++++ 3 files changed, 214 insertions(+), 36 deletions(-) diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java index c436c34f7e810..0602c4f3b2421 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java @@ -18,62 +18,69 @@ import java.util.Iterator; /** - * Allows a processor to route records from a single lazy iterator to multiple - * output connections, enabling true streaming split without buffering. + * Allows a processor to stream records lazily to multiple output connections + * without buffering, supporting two mutually exclusive modes per invocation: + * + *

    + *
  • Split mode ({@link #setIterator(Iterator)}): a single tagged iterator + * routes each record to a specific output connection via {@link TaggedOutput}. + * Use when records come from one shared source and must be split between outputs.
  • + *
  • Independent mode ({@link #setIterator(String, Iterator)}): each output + * connection gets its own independent lazy iterator, consumed in parallel by the drain + * loop. Use when each output has its own self-contained lazy source.
  • + *
* *

- * Unlike using separate {@link OutputIterator}s per output (which are consumed - * in lockstep by the drain loop), a {@code MultiOutputIterator} uses a single - * source iterator where each element is tagged with its target output name via - * {@link TaggedOutput}. The Studio DI runtime reads one tagged record at a time - * and routes it to the appropriate connection — enabling correct split behavior - * when different records go to different outputs. + * Both modes can be selected at runtime from the same fixed method parameter, + * so the component does not need separate {@link OutputIterator} parameters. * *

* Important: This interface is supported only in the Studio DI runtime. * *

- * Example usage (split in @ElementListener): + * Split mode example (one source, per-record routing): * *

  * {@code
  * 
  * @ElementListener
  * public void process(@Input Record input,
- *         @Output MultiOutputIterator splitter) {
- *     splitter.setIterator(myLazySplitIterator(input));
+ *         @Output MultiOutputIterator out) {
+ *     out.setIterator(
+ *             mySource.stream()
+ *                     .map(r -> isValid(r)
+ *                             ? TaggedOutput.of("MAIN", transform(r))
+ *                             : TaggedOutput.of("REJECT", r))
+ *                     .iterator());
  * }
  * }
  * 
* *

- * Example usage (bulk split in @AfterGroup): + * Independent mode example (each output has its own lazy source): * *

  * {@code
  * 
  * @AfterGroup
- * public void afterGroup(@Output MultiOutputIterator splitter) {
- *     splitter.setIterator(
- *             records.stream()
- *                     .map(r -> isValid(r)
- *                             ? TaggedOutput.of("MAIN", transform(r))
- *                             : TaggedOutput.of("REJECT", r))
- *                     .iterator());
+ * public void afterGroup(@Output MultiOutputIterator out) {
+ *     out.setIterator("MAIN", mainDatabase.lazyQuery());
+ *     out.setIterator("REJECT", errorLog.lazyRead());
  * }
  * }
  * 
* *

- * The drain loop on the Studio side should use per-connection - * {@code hasDataFor(name)} checks to efficiently consume only the records - * belonging to each output: + * The drain loop on the Studio side uses {@code hasDataFor(name)} for correct + * per-connection checking in both modes: * *

  * {@code
  * while (outputsHandler.hasMoreData()) {
- *     if (outputsHandler.hasDataFor("MAIN"))   mainRow   = outputsHandler.getValue("MAIN");
- *     if (outputsHandler.hasDataFor("REJECT"))  rejectRow = outputsHandler.getValue("REJECT");
+ *     if (outputsHandler.hasDataFor("MAIN"))
+ *         mainRow = outputsHandler.getValue("MAIN");
+ *     if (outputsHandler.hasDataFor("REJECT"))
+ *         rejectRow = outputsHandler.getValue("REJECT");
  * }
  * }
  * 
@@ -85,10 +92,27 @@ public interface MultiOutputIterator { /** - * Sets the lazy iterator that produces tagged output records on demand. - * Each {@link TaggedOutput} specifies which output connection receives the record. + * Split mode: sets a single lazy iterator whose elements are tagged with + * the target output connection name via {@link TaggedOutput}. + * The runtime reads one record at a time and routes it to the matching connection. + * + *

+ * Mutually exclusive with {@link #setIterator(String, Iterator)} within one invocation. * - * @param iterator the iterator producing tagged records + * @param iterator the tagged iterator routing records to their named outputs */ void setIterator(Iterator> iterator); + + /** + * Independent mode: assigns a lazy iterator to a specific named output connection. + * Call once per output that needs a dedicated lazy source; connections without an + * assigned iterator fall back to the push-mode queue as usual. + * + *

+ * Mutually exclusive with {@link #setIterator(Iterator)} within one invocation. + * + * @param outputName the output connection name (e.g. {@code "MAIN"}, {@code "REJECT"}) + * @param iterator the lazy iterator producing records for that connection + */ + void setIterator(String outputName, Iterator iterator); } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index c564cffe89f1d..41ab4baa285ff 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -74,16 +74,44 @@ public Object next() { @Override public MultiOutputIterator createMultiOutputIterator() { - return iterator -> setTaggedSource(() -> { - if (!iterator.hasNext()) { - return false; + return new MultiOutputIterator() { + + @Override + public void setIterator(final Iterator> iterator) { + setTaggedSource(() -> { + if (!iterator.hasNext()) { + return false; + } + final TaggedOutput tagged = iterator.next(); + final String name = getActualName(tagged.getOutputName()); + final BaseIOHandler.IO ref = connections.get(name); + setPending(name, + ref != null ? convert(tagged.getRecord(), ref) : tagged.getRecord()); + return true; + }); } - final TaggedOutput tagged = iterator.next(); - final String name = getActualName(tagged.getOutputName()); - final BaseIOHandler.IO ref = connections.get(name); - setPending(name, ref != null ? convert(tagged.getRecord(), ref) : tagged.getRecord()); - return true; - }); + + @Override + public void setIterator(final String outputName, final Iterator iterator) { + final String name = getActualName(outputName); + final BaseIOHandler.IO ref = connections.get(name); + if (ref == null) { + return; + } + ref.setSource(new Iterator() { + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Object next() { + return convert(iterator.next(), ref); + } + }); + } + }; } }; } diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 96e5744d23cde..298afa9b5c1e0 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -399,6 +399,84 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { chunkProcessor.stop(); } + /** + * Tests that a processor using {@link MultiOutputIterator#setIterator(String, Iterator)} + * (independent mode) correctly streams independent lazy sources per output connection, + * equivalent to using two separate {@link OutputIterator} parameters but from one + * fixed method parameter. + */ + @org.junit.jupiter.api.Test + void multiOutputIteratorIndependentModeShouldStreamPerConnection() { + final ComponentManager manager = ComponentManager.instance(); + final Map, Object> servicesMapper = getServicesMapper(manager); + final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); + builderFactory = (RecordBuilderFactory) servicesMapper.get(RecordBuilderFactory.class); + + final org.talend.sdk.component.runtime.output.Processor processor = manager + .findProcessor("ProcessorBufferingTest", "independentEmitter", 1, new HashMap<>()) + .orElseThrow(() -> new IllegalStateException("independentEmitter processor not found")); + JobStateAware.init(processor, new HashMap<>()); + + final AutoChunkProcessor chunkProcessor = new AutoChunkProcessor(RECORD_COUNT + 1, processor); + chunkProcessor.start(); + + final InputsHandler inputsHandler = new InputsHandler(jsonb, servicesMapper); + inputsHandler.addConnection("FLOW", row1Struct.class); + + final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); + outputsHandler.addConnection("MAIN", row1Struct.class); + outputsHandler.addConnection("REJECT", row1Struct.class); + + final InputFactory inputFactory = inputsHandler.asInputFactory(); + final OutputFactory outputFactory = outputsHandler.asOutputFactory(); + + final Record inputRecord = builderFactory.newRecordBuilder() + .withString("id", "input-1") + .withString("name", "trigger") + .build(); + final RecordConverters.MappingMetaRegistry registry = new RecordConverters.MappingMetaRegistry(); + final row1Struct inputRow = (row1Struct) registry.find(row1Struct.class).newInstance(inputRecord); + inputsHandler.setInputValue("FLOW", inputRow); + + gc(); + final long memoryBefore = usedMemoryMB(); + + chunkProcessor.onElement(inputFactory, outputFactory); + + // Independent mode: drain each connection with hasDataFor + int mainCount = 0; + int rejectCount = 0; + while (outputsHandler.hasMoreData()) { + if (outputsHandler.hasDataFor("MAIN")) { + outputsHandler.getValue("MAIN"); + mainCount++; + } + if (outputsHandler.hasDataFor("REJECT")) { + outputsHandler.getValue("REJECT"); + rejectCount++; + } + } + + chunkProcessor.flush(outputFactory); + gc(); + final long memoryDelta = usedMemoryMB() - memoryBefore; + + System.out.println("=== ProcessorBufferingTest: MultiOutputIterator independent mode ==="); + System.out.println("MAIN drained: " + mainCount); + System.out.println("REJECT drained: " + rejectCount); + System.out.println("Memory delta: " + memoryDelta + " MB"); + + // MAIN has RECORD_COUNT records, REJECT has RECORD_COUNT/2 + org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT, mainCount, + "MAIN should receive all records"); + org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT / 2, rejectCount, + "REJECT should receive half the records"); + org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); + + chunkProcessor.stop(); + } + // --- Test components --- /** @@ -573,6 +651,54 @@ public TaggedOutput next() { } } + /** + * Processor that uses {@link MultiOutputIterator#setIterator(String, Iterator)} + * (independent mode) to assign separate lazy sources to MAIN and REJECT. + * MAIN gets RECORD_COUNT records, REJECT gets RECORD_COUNT/2 records. + */ + @Processor(name = "independentEmitter", family = "ProcessorBufferingTest") + public static class IndependentEmitterProcessor implements Serializable { + + @ElementListener + public void onElement(@Input final Record input, + @Output final MultiOutputIterator out) { + out.setIterator("MAIN", new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + return index < RECORD_COUNT; + } + + @Override + public Record next() { + return builderFactory.newRecordBuilder() + .withString("id", "main-" + index) + .withString("name", "main-record-" + index++) + .build(); + } + }); + out.setIterator("REJECT", new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + return index < RECORD_COUNT / 2; + } + + @Override + public Record next() { + return builderFactory.newRecordBuilder() + .withString("id", "reject-" + index) + .withString("name", "reject-record-" + index++) + .build(); + } + }); + } + } + private void gc() { // GC to reclaim input record objects from the loop System.gc(); From d9391c23c63601087dc3c89598b7ac1392a07314 Mon Sep 17 00:00:00 2001 From: wwang Date: Thu, 16 Jul 2026 18:03:17 +0800 Subject: [PATCH 15/21] feat: enhance MultiOutputIterator with independent mode for separate lazy sources --- .../component/runtime/di/BaseIOHandler.java | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index b45162dc9f0f0..8ec9ddf123e20 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -54,6 +54,15 @@ protected interface TaggedAdvancer { boolean advance(); } + /** + * Flat array of all registered {@link IO} values. + * Cached after the first call to {@link #ioArray()} and invalidated whenever + * {@link #addConnection} or {@link #init} modifies {@link #connections}. + * Using an array in the hot drain loop avoids creating a new {@link java.util.Iterator} + * on each {@link #hasMoreData()} invocation. + */ + private IO[] ioCache; + /** * Tagged-source advancer for split streaming. * Non-null only when the component used a @@ -88,15 +97,28 @@ public void init(final Collection branchesOrder) { } if (!mapping.isEmpty()) { mapping.forEach((row, branch) -> connections.putIfAbsent(branch, connections.get(row))); + ioCache = null; } } public void addConnection(final String connectorName, final Class type) { connections.put(connectorName, new IO<>(type)); + ioCache = null; + } + + /** Returns a cached flat array of all registered IO objects for allocation-free iteration. */ + @SuppressWarnings("unchecked") + private IO[] ioArray() { + if (ioCache == null) { + ioCache = connections.values().toArray(IO[]::new); + } + return ioCache; } public void reset() { - connections.values().forEach(IO::reset); + for (final IO io : ioArray()) { + io.reset(); + } taggedSource = null; pendingOutputName = null; pendingRecord = null; @@ -119,7 +141,12 @@ public boolean hasMoreData() { if (taggedSource != null) { return pendingOutputName != null || taggedSource.advance(); } - return connections.entrySet().stream().anyMatch(e -> e.getValue().hasNext()); + for (final IO io : ioArray()) { + if (io.hasNext()) { + return true; + } + } + return false; } /** @@ -205,6 +232,11 @@ protected String getActualName(final String name) { * Records are produced on-demand during the drain loop ({@link #hasNext()}/{@link #next()}). * Used by {@code OutputIterator.setIterator()} in the Studio DI runtime. * + * In pull mode, one record is eagerly buffered by {@link #hasNext()} so that + * subsequent calls to {@link #hasNext()} and {@link #next()} make no further + * calls to the source iterator — eliminating repeated {@code source.hasNext()} + * invocations across {@code hasMoreData()}, {@code hasDataFor()}, and {@code getValue()}. + *

* Note: The {@code source} field is only used by {@code OutputsHandler}; {@code InputsHandler} * uses only the queue-based push mode. */ @@ -217,29 +249,51 @@ static class IO { private Iterator source; + /** One-record look-ahead buffer for pull mode. Non-null iff {@link #bufferFull} is true. */ + private T buffered; + + /** True when {@link #buffered} holds a record ready to be returned by {@link #next()}. */ + private boolean bufferFull; + void setSource(final Iterator source) { - // Close previous source if it implements AutoCloseable closeSource(); this.source = source; + this.buffered = null; + this.bufferFull = false; } private void reset() { values.clear(); closeSource(); this.source = null; + this.buffered = null; + this.bufferFull = false; } boolean hasNext() { - return !values.isEmpty() - || (source != null && source.hasNext()); + if (!values.isEmpty()) { + return true; + } + if (bufferFull) { + return true; + } + if (source != null && source.hasNext()) { + buffered = type.cast(source.next()); + bufferFull = true; + return true; + } + return false; } T next() { if (!values.isEmpty()) { return type.cast(values.poll()); } - if (source != null && source.hasNext()) { - return type.cast(source.next()); + if (bufferFull) { + final T val = buffered; + buffered = null; + bufferFull = false; + return val; } return null; } From b5bfa043f40c67b832d15ec2448ad9a4d9321b98 Mon Sep 17 00:00:00 2001 From: wwang Date: Fri, 17 Jul 2026 09:34:59 +0800 Subject: [PATCH 16/21] refactor: update references from OutputIterator to MultiOutputIterator in documentation and tests --- .../api/processor/MultiOutputIterator.java | 3 +- .../api/processor/OutputIterator.java | 61 ------------------- .../runtime/output/OutputFactory.java | 25 ++------ .../runtime/output/ProcessorImpl.java | 7 --- .../runtime/visitor/ModelVisitor.java | 6 +- .../component/runtime/di/BaseIOHandler.java | 2 +- .../component/runtime/di/OutputsHandler.java | 23 ------- .../di/studio/ProcessorBufferingTest.java | 14 ++--- .../tools/ComponentValidatorTest.java | 2 +- 9 files changed, 15 insertions(+), 128 deletions(-) delete mode 100644 component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java index 0602c4f3b2421..2415340abdc82 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/MultiOutputIterator.java @@ -32,7 +32,7 @@ * *

* Both modes can be selected at runtime from the same fixed method parameter, - * so the component does not need separate {@link OutputIterator} parameters. + * so the component does not need separate parameters per output. * *

* Important: This interface is supported only in the Studio DI runtime. @@ -87,7 +87,6 @@ * * @param the record type * @see TaggedOutput - * @see OutputIterator */ public interface MultiOutputIterator { diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java deleted file mode 100644 index cfd62d32ebfe6..0000000000000 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/OutputIterator.java +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright (C) 2006-2026 Talend Inc. - www.talend.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.talend.sdk.component.api.processor; - -import java.util.Iterator; - -/** - * Allows a processor to provide a lazy iterator for output records - * instead of pushing them via {@link OutputEmitter#emit(Object)}. - * - *

- * Used with {@code @Output} on an {@code OutputIterator} parameter to enable streaming: - * records are produced on-demand during the consumer's drain loop, - * rather than buffered entirely in memory. - * - *

- * Important: This interface is supported only in the Studio DI runtime. - * It is not supported in Beam-based runners (Cloud) where processors typically - * do not use TCK processor patterns. Using {@code OutputIterator} in a Beam - * pipeline will result in a {@code ClassCastException} at runtime. - * - *

- * Example usage in a processor: - * - *

- * {@code
- * 
- * @ElementListener
- * public void process(@Input Record input,
- *         @Output OutputIterator output) {
- *     output.setIterator(myLazyIterator(input));
- * }
- * }
- * 
- * - * @param the record type - */ -public interface OutputIterator { - - /** - * Sets the lazy iterator that will produce output records on demand. - * Call this once per invocation; the consumer will pull records - * by calling {@link Iterator#hasNext()} and {@link Iterator#next()}. - * - * @param iterator the lazy iterator providing output records - */ - void setIterator(Iterator iterator); -} diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java index ed4d7595e2fcd..44d09b04c78ce 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/OutputFactory.java @@ -17,37 +17,20 @@ import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.OutputEmitter; -import org.talend.sdk.component.api.processor.OutputIterator; public interface OutputFactory { OutputEmitter create(String name); /** - * Creates an {@link OutputIterator} for the given output branch name. - * Supported only in the Studio DI runtime. Other runtimes throw - * {@link UnsupportedOperationException} by default. - * - * @param name the output branch name - * @return an OutputIterator for lazy record streaming - * @throws UnsupportedOperationException if the runtime does not support iterator mode - */ - default OutputIterator createIterator(String name) { - throw new UnsupportedOperationException( - "OutputIterator is only supported in the Studio DI runtime"); - } - - /** - * Creates a {@link MultiOutputIterator} that routes tagged records to multiple - * output connections from a single lazy iterator. + * Creates a {@link MultiOutputIterator} that routes records lazily to one or more + * output connections without buffering. * *

- * Supported only in the Studio DI runtime. Use this instead of multiple - * {@link OutputIterator}s when records must be split across outputs from a - * shared source without buffering. + * Supported only in the Studio DI runtime. * * @param the record type - * @return a MultiOutputIterator for tagged split streaming + * @return a MultiOutputIterator for lazy streaming * @throws UnsupportedOperationException if the runtime does not support multi-output iterator mode */ default MultiOutputIterator createMultiOutputIterator() { diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index a22984e78c6cd..1ee55208ab721 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -57,7 +57,6 @@ import org.talend.sdk.component.api.processor.LastGroup; import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.Output; -import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.service.record.RecordBuilderFactory; import org.talend.sdk.component.runtime.base.Delegated; import org.talend.sdk.component.runtime.base.LifecycleImpl; @@ -153,9 +152,6 @@ public void beforeGroup() { private BiFunction buildProcessParamBuilder(final Parameter parameter) { if (parameter.isAnnotationPresent(Output.class)) { final String name = parameter.getAnnotation(Output.class).value(); - if (OutputIterator.class == parameter.getType()) { - return (inputs, outputs) -> outputs.createIterator(name); - } if (MultiOutputIterator.class == parameter.getType()) { return (inputs, outputs) -> outputs.createMultiOutputIterator(); } @@ -174,9 +170,6 @@ private Function toOutputParamBuilder(final Parameter par return false; } final String name = parameter.getAnnotation(Output.class).value(); - if (OutputIterator.class == parameter.getType()) { - return outputs.createIterator(name); - } if (MultiOutputIterator.class == parameter.getType()) { return outputs.createMultiOutputIterator(); } diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java index ba12827c9852b..f34e928f28702 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/visitor/ModelVisitor.java @@ -47,7 +47,6 @@ import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.Output; import org.talend.sdk.component.api.processor.OutputEmitter; -import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.processor.Processor; import org.talend.sdk.component.api.standalone.DriverRunner; import org.talend.sdk.component.api.standalone.RunAtDriver; @@ -198,7 +197,7 @@ private void validateProcessor(final Class input) { final List invalidParams = Stream.of(m.getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { throw new IllegalArgumentException( - "@Output parameter must be of type OutputEmitter, OutputIterator, or MultiOutputIterator"); + "@Output parameter must be of type OutputEmitter or MultiOutputIterator"); } }) .filter(p -> !p.isAnnotationPresent(Output.class)) @@ -247,7 +246,7 @@ private void validateProducer(final Class input, final List afterGrou if (!producers.isEmpty() && Stream.of(producers.get(0).getParameters()).peek(p -> { if (p.isAnnotationPresent(Output.class) && !validOutputParam(p)) { throw new IllegalArgumentException( - "@Output parameter must be of type OutputEmitter, OutputIterator, or MultiOutputIterator"); + "@Output parameter must be of type OutputEmitter or MultiOutputIterator"); } }).filter(p -> !p.isAnnotationPresent(Output.class)).count() < 1) { throw new IllegalArgumentException(input + " doesn't have the input parameter on its producer method"); @@ -259,7 +258,6 @@ private boolean validOutputParam(final Parameter p) { return false; } return OutputEmitter.class == pt.getRawType() - || OutputIterator.class == pt.getRawType() || MultiOutputIterator.class == pt.getRawType(); } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index 8ec9ddf123e20..da264c6a326bc 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -230,7 +230,7 @@ protected String getActualName(final String name) { * Used by {@code OutputEmitter.emit()}. *

  • Pull mode (iterator): a lazy {@link Iterator} source is set via {@link #setSource(Iterator)}. * Records are produced on-demand during the drain loop ({@link #hasNext()}/{@link #next()}). - * Used by {@code OutputIterator.setIterator()} in the Studio DI runtime.
  • + * Used by {@code MultiOutputIterator.setIterator(String, Iterator)} in the Studio DI runtime. * * In pull mode, one record is eagerly buffered by {@link #hasNext()} so that * subsequent calls to {@link #hasNext()} and {@link #next()} make no further diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index 41ab4baa285ff..b3bacbc1b7d3c 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -22,7 +22,6 @@ import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.OutputEmitter; -import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.processor.TaggedOutput; import org.talend.sdk.component.api.record.Record; import org.talend.sdk.component.api.record.Schema; @@ -50,28 +49,6 @@ public OutputEmitter create(final String name) { }; } - @Override - public OutputIterator createIterator(final String name) { - final BaseIOHandler.IO ref = connections.get(getActualName(name)); - return iterator -> { - if (ref == null) { - return; - } - ref.setSource(new Iterator() { - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public Object next() { - return convert(iterator.next(), ref); - } - }); - }; - } - @Override public MultiOutputIterator createMultiOutputIterator() { return new MultiOutputIterator() { diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 298afa9b5c1e0..3cdbbe1f32d0e 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -37,7 +37,6 @@ import org.talend.sdk.component.api.processor.LastGroup; import org.talend.sdk.component.api.processor.MultiOutputIterator; import org.talend.sdk.component.api.processor.Output; -import org.talend.sdk.component.api.processor.OutputIterator; import org.talend.sdk.component.api.processor.Processor; import org.talend.sdk.component.api.processor.TaggedOutput; import org.talend.sdk.component.api.record.Record; @@ -402,7 +401,7 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { /** * Tests that a processor using {@link MultiOutputIterator#setIterator(String, Iterator)} * (independent mode) correctly streams independent lazy sources per output connection, - * equivalent to using two separate {@link OutputIterator} parameters but from one + * equivalent to using two separate output parameters but from one * fixed method parameter. */ @org.junit.jupiter.api.Test @@ -488,8 +487,8 @@ public static class HeavyEmitterProcessor implements Serializable { @ElementListener public void onElement(@Input final Record input, - @Output final OutputIterator output) { - output.setIterator(new java.util.Iterator<>() { + @Output final MultiOutputIterator output) { + output.setIterator("FLOW", new java.util.Iterator<>() { private int index = 0; @@ -532,15 +531,14 @@ public void onElement(@Input final Record input) { } @AfterGroup - public void afterGroup(@Output("MAIN") final OutputIterator main, - @Output("REJECT") final OutputIterator reject, + public void afterGroup(@Output final MultiOutputIterator out, @LastGroup final boolean lastGroup) { if (!lastGroup) { return; } // MAIN gets records where index % 2 != 0 - main.setIterator(new java.util.Iterator<>() { + out.setIterator("MAIN", new java.util.Iterator<>() { private int index = 0; @@ -565,7 +563,7 @@ public Record next() { }); // REJECT gets records where index % 2 == 0 - reject.setIterator(new java.util.Iterator<>() { + out.setIterator("REJECT", new java.util.Iterator<>() { private int index = 0; diff --git a/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java b/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java index 65bdc0ea5e49b..6aa430433b7c7 100755 --- a/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java +++ b/component-tools/src/test/java/org/talend/sdk/component/tools/ComponentValidatorTest.java @@ -571,7 +571,7 @@ void testFailureAfterGroup(final ExceptionSpec expectedException) { expectedException .expectMessage( """ - - @Output parameter must be of type OutputEmitter or OutputIterator + - @Output parameter must be of type OutputEmitter or MultiOutputIterator - Parameter of AfterGroup method need to be annotated with Output - class org.talend.test.failure.aftergroup.MyComponent5 must have a single @AfterGroup method with @LastGroup parameter"""); } From cbc6b1044459d2289d7a48b5ca96cb255f8a372b Mon Sep 17 00:00:00 2001 From: wwang Date: Fri, 17 Jul 2026 11:15:51 +0800 Subject: [PATCH 17/21] feat: enhance hasDataFor method to skip unregistered pending records and improve split streaming tests --- .../component/runtime/di/BaseIOHandler.java | 18 +- .../di/studio/ProcessorBufferingTest.java | 287 +++++++++++++++--- 2 files changed, 269 insertions(+), 36 deletions(-) diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index da264c6a326bc..d4dd1ccfed25e 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -180,10 +180,26 @@ public boolean hasMoreData() { */ public boolean hasDataFor(final String connectionName) { if (taggedSource != null) { + // Skip any pending record for an unregistered connection before checking + while (pendingOutputName != null && !connections.containsKey(pendingOutputName)) { + pendingOutputName = null; + pendingRecord = null; + if (!taggedSource.advance()) { + return false; + } + } if (pendingOutputName != null) { return pendingOutputName.equals(connectionName); } - return taggedSource.advance() && pendingOutputName.equals(connectionName); + // Advance, skipping records for unregistered connections + while (taggedSource.advance()) { + if (connections.containsKey(pendingOutputName)) { + return pendingOutputName.equals(connectionName); + } + pendingOutputName = null; + pendingRecord = null; + } + return false; } final IO io = connections.get(connectionName); return io != null && io.hasNext(); diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 3cdbbe1f32d0e..54432f277d520 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -300,6 +300,132 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { + "but was " + memoryDelta + " MB (all records buffered in memory)"); } + /** + * Tests that a processor using a split {@link MultiOutputIterator} in {@code @AfterGroup} + * correctly routes records between MAIN and REJECT via {@code hasDataFor} without buffering. + * + *

    + * Uses a single shared tagged iterator that routes even-indexed records to MAIN and + * odd-indexed records to REJECT. The drain loop uses {@code hasDataFor} to consume + * only the connection that has a record ready — which exercises the fix in + * {@code hasDataFor} that skips unregistered pending records instead of stalling. + */ + @ParameterizedTest + @EnumSource(OutputMode.class) + void afterGroupSplitIteratorShouldRouteRecordsWithHasDataFor(OutputMode outputMode) { + final ComponentManager manager = ComponentManager.instance(); + final Map, Object> servicesMapper = getServicesMapper(manager); + final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); + builderFactory = (RecordBuilderFactory) servicesMapper.get(RecordBuilderFactory.class); + + final org.talend.sdk.component.runtime.output.Processor processor = manager + .findProcessor("ProcessorBufferingTest", "splitAfterGroupEmitter", 1, new HashMap<>()) + .orElseThrow(() -> new IllegalStateException("splitAfterGroupEmitter processor not found")); + JobStateAware.init(processor, new HashMap<>()); + + // chunkSize=5: after 5 inputs, @AfterGroup fires and emits 100K records via split iterator + final AutoChunkProcessor chunkProcessor = new AutoChunkProcessor(5, processor); + chunkProcessor.start(); + + final InputsHandler inputsHandler = new InputsHandler(jsonb, servicesMapper); + inputsHandler.addConnection("FLOW", row1Struct.class); + + final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); + if (outputMode != OutputMode.NO_OUTPUT) { + outputsHandler.addConnection("MAIN", row1Struct.class); + if (outputMode == OutputMode.TWO_OUTPUT) { + outputsHandler.addConnection("REJECT", row1Struct.class); + } + } + + final InputFactory inputFactory = inputsHandler.asInputFactory(); + final OutputFactory outputFactory = outputsHandler.asOutputFactory(); + final RecordConverters.MappingMetaRegistry registry = new RecordConverters.MappingMetaRegistry(); + + int mainCount = 0; + int rejectCount = 0; + + gc(); + final long memoryBefore = usedMemoryMB(); + + for (int i = 0; i < 5; i++) { + final Record inputRecord = builderFactory.newRecordBuilder() + .withString("id", "input-" + i) + .withString("name", "record-" + i) + .build(); + final row1Struct inputRow = (row1Struct) registry.find(row1Struct.class).newInstance(inputRecord); + inputsHandler.setInputValue("FLOW", inputRow); + + chunkProcessor.onElement(inputFactory, outputFactory); + + if (outputMode != OutputMode.NO_OUTPUT) { + // Drain using hasDataFor — exercises the fix that skips unregistered pending records + while (outputsHandler.hasMoreData()) { + if (outputsHandler.hasDataFor("MAIN")) { + outputsHandler.getValue("MAIN"); + mainCount++; + } + if (outputMode == OutputMode.TWO_OUTPUT && outputsHandler.hasDataFor("REJECT")) { + outputsHandler.getValue("REJECT"); + rejectCount++; + } + } + } + } + + chunkProcessor.flush(outputFactory); + + // Drain after flush — @AfterGroup with lastGroup=true fires inside flush(), + // so the split iterator is only set there and must be drained here. + if (outputMode != OutputMode.NO_OUTPUT) { + while (outputsHandler.hasMoreData()) { + if (outputsHandler.hasDataFor("MAIN")) { + outputsHandler.getValue("MAIN"); + mainCount++; + } + if (outputMode == OutputMode.TWO_OUTPUT && outputsHandler.hasDataFor("REJECT")) { + outputsHandler.getValue("REJECT"); + rejectCount++; + } + } + } + + gc(); + + final long memoryAfter = usedMemoryMB(); + final long memoryDelta = memoryAfter - memoryBefore; + + System.out.println("=== ProcessorBufferingTest: @AfterGroup split iterator ==="); + System.out.println("Output mode: " + outputMode); + System.out.println("MAIN drained: " + mainCount); + System.out.println("REJECT drained: " + rejectCount); + System.out.println("Memory delta: " + memoryDelta + " MB"); + + final int half = RECORD_COUNT / 2; + switch (outputMode) { + case TWO_OUTPUT: + org.junit.jupiter.api.Assertions.assertEquals(half, mainCount, + "MAIN should receive half the records (even indices)"); + org.junit.jupiter.api.Assertions.assertEquals(half, rejectCount, + "REJECT should receive half the records (odd indices)"); + break; + case ONE_OUTPUT: + org.junit.jupiter.api.Assertions.assertEquals(half, mainCount, + "MAIN should receive half the records"); + org.junit.jupiter.api.Assertions.assertEquals(0, rejectCount, + "REJECT not registered, should receive nothing"); + break; + case NO_OUTPUT: + org.junit.jupiter.api.Assertions.assertEquals(0, mainCount + rejectCount, + "No connections registered, no records should be received"); + break; + } + org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); + + chunkProcessor.stop(); + } + // --- Helpers --- private static long usedMemoryMB() { @@ -324,8 +450,9 @@ private Map, Object> getServicesMapper(ComponentManager manager) { * from a SINGLE shared source iterator. Without tagged-output support this would * require buffering all records first. */ - @org.junit.jupiter.api.Test - void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { + @ParameterizedTest + @EnumSource(OutputMode.class) + void multiOutputIteratorShouldSplitRecordsWithoutBuffering(OutputMode outputMode) { final ComponentManager manager = ComponentManager.instance(); final Map, Object> servicesMapper = getServicesMapper(manager); final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); @@ -343,8 +470,12 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { inputsHandler.addConnection("FLOW", row1Struct.class); final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); - outputsHandler.addConnection("MAIN", row1Struct.class); - outputsHandler.addConnection("REJECT", row1Struct.class); + if (outputMode != OutputMode.NO_OUTPUT) { + outputsHandler.addConnection("MAIN", row1Struct.class); + if (outputMode == OutputMode.TWO_OUTPUT) { + outputsHandler.addConnection("REJECT", row1Struct.class); + } + } final InputFactory inputFactory = inputsHandler.asInputFactory(); final OutputFactory outputFactory = outputsHandler.asOutputFactory(); @@ -365,14 +496,18 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { // Drain using per-connection hasDataFor checks — correct for split streaming int mainCount = 0; int rejectCount = 0; - while (outputsHandler.hasMoreData()) { - if (outputsHandler.hasDataFor("MAIN")) { - outputsHandler.getValue("MAIN"); - mainCount++; - } - if (outputsHandler.hasDataFor("REJECT")) { - outputsHandler.getValue("REJECT"); - rejectCount++; + if (outputMode != OutputMode.NO_OUTPUT) { + while (outputsHandler.hasMoreData()) { + if (outputsHandler.hasDataFor("MAIN")) { + outputsHandler.getValue("MAIN"); + mainCount++; + } + if (outputMode == OutputMode.TWO_OUTPUT) { + if (outputsHandler.hasDataFor("REJECT")) { + outputsHandler.getValue("REJECT"); + rejectCount++; + } + } } } @@ -386,12 +521,28 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { System.out.println("REJECT drained: " + rejectCount); System.out.println("Memory delta: " + memoryDelta + " MB"); - // Split should route exactly half to each final int expected = RECORD_COUNT / 2; - org.junit.jupiter.api.Assertions.assertEquals(expected, mainCount, - "MAIN should receive half the records"); - org.junit.jupiter.api.Assertions.assertEquals(expected, rejectCount, - "REJECT should receive the other half"); + switch (outputMode) { + case TWO_OUTPUT: + // Both connections registered: each receives exactly half + org.junit.jupiter.api.Assertions.assertEquals(expected, mainCount, + "MAIN should receive half the records"); + org.junit.jupiter.api.Assertions.assertEquals(expected, rejectCount, + "REJECT should receive the other half"); + break; + case ONE_OUTPUT: + // Only MAIN registered: MAIN gets its half, REJECT records are skipped + org.junit.jupiter.api.Assertions.assertEquals(expected, mainCount, + "MAIN should receive half the records"); + org.junit.jupiter.api.Assertions.assertEquals(0, rejectCount, + "REJECT not registered, should receive nothing"); + break; + case NO_OUTPUT: + // No connections registered: nothing drained + org.junit.jupiter.api.Assertions.assertEquals(0, mainCount + rejectCount, + "No connections registered, no records should be received"); + break; + } org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); @@ -404,8 +555,9 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering() { * equivalent to using two separate output parameters but from one * fixed method parameter. */ - @org.junit.jupiter.api.Test - void multiOutputIteratorIndependentModeShouldStreamPerConnection() { + @ParameterizedTest + @EnumSource(OutputMode.class) + void multiOutputIteratorIndependentModeShouldStreamPerConnection(OutputMode outputMode) { final ComponentManager manager = ComponentManager.instance(); final Map, Object> servicesMapper = getServicesMapper(manager); final Jsonb jsonb = (Jsonb) servicesMapper.get(Jsonb.class); @@ -423,8 +575,12 @@ void multiOutputIteratorIndependentModeShouldStreamPerConnection() { inputsHandler.addConnection("FLOW", row1Struct.class); final OutputsHandler outputsHandler = new OutputsHandler(jsonb, servicesMapper); - outputsHandler.addConnection("MAIN", row1Struct.class); - outputsHandler.addConnection("REJECT", row1Struct.class); + if (outputMode != OutputMode.NO_OUTPUT) { + outputsHandler.addConnection("MAIN", row1Struct.class); + if (outputMode == OutputMode.TWO_OUTPUT) { + outputsHandler.addConnection("REJECT", row1Struct.class); + } + } final InputFactory inputFactory = inputsHandler.asInputFactory(); final OutputFactory outputFactory = outputsHandler.asOutputFactory(); @@ -445,14 +601,16 @@ void multiOutputIteratorIndependentModeShouldStreamPerConnection() { // Independent mode: drain each connection with hasDataFor int mainCount = 0; int rejectCount = 0; - while (outputsHandler.hasMoreData()) { - if (outputsHandler.hasDataFor("MAIN")) { - outputsHandler.getValue("MAIN"); - mainCount++; - } - if (outputsHandler.hasDataFor("REJECT")) { - outputsHandler.getValue("REJECT"); - rejectCount++; + if (outputMode != OutputMode.NO_OUTPUT) { + while (outputsHandler.hasMoreData()) { + if (outputsHandler.hasDataFor("MAIN")) { + outputsHandler.getValue("MAIN"); + mainCount++; + } + if (outputMode == OutputMode.TWO_OUTPUT && outputsHandler.hasDataFor("REJECT")) { + outputsHandler.getValue("REJECT"); + rejectCount++; + } } } @@ -461,15 +619,29 @@ void multiOutputIteratorIndependentModeShouldStreamPerConnection() { final long memoryDelta = usedMemoryMB() - memoryBefore; System.out.println("=== ProcessorBufferingTest: MultiOutputIterator independent mode ==="); + System.out.println("Output mode: " + outputMode); System.out.println("MAIN drained: " + mainCount); System.out.println("REJECT drained: " + rejectCount); System.out.println("Memory delta: " + memoryDelta + " MB"); - // MAIN has RECORD_COUNT records, REJECT has RECORD_COUNT/2 - org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT, mainCount, - "MAIN should receive all records"); - org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT / 2, rejectCount, - "REJECT should receive half the records"); + switch (outputMode) { + case TWO_OUTPUT: + org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT, mainCount, + "MAIN should receive all records"); + org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT / 2, rejectCount, + "REJECT should receive half the records"); + break; + case ONE_OUTPUT: + org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT, mainCount, + "MAIN should receive all records"); + org.junit.jupiter.api.Assertions.assertEquals(0, rejectCount, + "REJECT not registered, should receive nothing"); + break; + case NO_OUTPUT: + org.junit.jupiter.api.Assertions.assertEquals(0, mainCount + rejectCount, + "No connections registered, no records should be received"); + break; + } org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); @@ -586,8 +758,53 @@ public Record next() { return rec; } }); + } + } + + /** + * Processor that uses a single shared split {@link MultiOutputIterator} in {@code @AfterGroup} + * to route even-indexed records to MAIN and odd-indexed records to REJECT via {@link TaggedOutput}. + * This tests the {@code hasDataFor} fix: when only MAIN is registered, odd-indexed pending + * records must be skipped rather than stalling the drain loop. + */ + @Processor(name = "splitAfterGroupEmitter", family = "ProcessorBufferingTest") + public static class SplitAfterGroupEmitterProcessor implements Serializable { - inputCount = 0; + @BeforeGroup + public void beforeGroup() { + } + + @ElementListener + public void onElement(@Input final Record input) { + } + + @AfterGroup + public void afterGroup(@Output final MultiOutputIterator out, + @LastGroup final boolean lastGroup) { + if (!lastGroup) { + return; + } + out.setIterator(new java.util.Iterator<>() { + + private int index = 0; + + @Override + public boolean hasNext() { + return index < RECORD_COUNT; + } + + @Override + public TaggedOutput next() { + final String outputName = (index % 2 == 0) ? "MAIN" : "REJECT"; + final Record rec = builderFactory.newRecordBuilder() + .withString("id", outputName.toLowerCase() + "-" + index) + .withString("name", "split-aftergroup-record-" + index) + .withString("data", "split-aftergroup-data-" + index) + .build(); + index++; + return TaggedOutput.of(outputName, rec); + } + }); } } From f0b35b3b24cdfd3783a78f7b60493e22af3e6c60 Mon Sep 17 00:00:00 2001 From: wwang Date: Fri, 17 Jul 2026 11:27:09 +0800 Subject: [PATCH 18/21] test: enhance ProcessorBufferingTest with additional assertions for record validity --- .../di/studio/ProcessorBufferingTest.java | 115 ++++++++++++------ 1 file changed, 78 insertions(+), 37 deletions(-) diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index 54432f277d520..ea6007453833e 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -15,8 +15,6 @@ */ package org.talend.sdk.component.runtime.di.studio; -import static org.junit.jupiter.api.Assertions.assertTrue; - import java.io.File; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; @@ -27,6 +25,7 @@ import javax.json.bind.Jsonb; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; @@ -144,7 +143,10 @@ void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { if (outputMode != OutputMode.NO_OUTPUT) { // sim inside of input loop while (outputsHandler.hasMoreData()) { - outputsHandler.getValue("FLOW"); + final row1Struct rec = outputsHandler.getValue("FLOW"); + Assertions.assertNotNull(rec, "FLOW record should not be null"); + Assertions.assertEquals("out-" + drainedCount, rec.id, + "FLOW record id mismatch at index " + drainedCount); drainedCount++; } } @@ -169,7 +171,10 @@ void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { if (outputMode != OutputMode.NO_OUTPUT) { // sim outside of input loop while (outputsHandler.hasMoreData()) { - outputsHandler.getValue("FLOW"); + final row1Struct rec = outputsHandler.getValue("FLOW"); + Assertions.assertNotNull(rec, "FLOW record should not be null"); + Assertions.assertEquals("out-" + drainedCount, rec.id, + "FLOW record id mismatch at index " + drainedCount); drainedCount++; } } @@ -180,7 +185,7 @@ void elementListenerShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { // ASSERTION: memory delta should be under threshold // FAILS on current code (all records buffered → large delta) // PASSES after iterator implementation (lazy streaming → small delta) - assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB (streaming), " + "but was " + memoryDelta + " MB (all records buffered in memory)"); } @@ -244,11 +249,17 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { if (outputMode != OutputMode.NO_OUTPUT) { // Drain after each onElement — same as Studio generated code while (outputsHandler.hasMoreData()) { - if (outputsHandler.getValue("MAIN") != null) { + final row1Struct mainRow = outputsHandler.getValue("MAIN"); + if (mainRow != null) { + Assertions.assertEquals("result-" + (mainCount * 2 + 1), mainRow.id, + "MAIN record id mismatch at index " + mainCount); mainCount++; } if (outputMode == OutputMode.TWO_OUTPUT) { - if (outputsHandler.getValue("REJECT") != null) { + final row1Struct rejectRow = outputsHandler.getValue("REJECT"); + if (rejectRow != null) { + Assertions.assertEquals("reject-" + (rejectCount * 2), rejectRow.id, + "REJECT record id mismatch at index " + rejectCount); rejectCount++; } } @@ -272,18 +283,24 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { if (outputMode != OutputMode.NO_OUTPUT) { while (outputsHandler.hasMoreData()) { - if (outputsHandler.getValue("MAIN") != null) { + final row1Struct mainRow = outputsHandler.getValue("MAIN"); + if (mainRow != null) { + Assertions.assertEquals("result-" + (mainCount * 2 + 1), mainRow.id, + "MAIN record id mismatch at index " + mainCount); mainCount++; } if (outputMode == OutputMode.TWO_OUTPUT) { - if (outputsHandler.getValue("REJECT") != null) { + final row1Struct rejectRow = outputsHandler.getValue("REJECT"); + if (rejectRow != null) { + Assertions.assertEquals("reject-" + (rejectCount * 2), rejectRow.id, + "REJECT record id mismatch at index " + rejectCount); rejectCount++; } } } // Verify all records were produced - assertTrue(mainCount + rejectCount > 0, + Assertions.assertTrue(mainCount + rejectCount > 0, "Should have produced records in @AfterGroup"); } @@ -295,7 +312,7 @@ void afterGroupShouldNotBufferAllRecordsInMemory(OutputMode outputMode) { // ASSERTION: memory delta should be under threshold // FAILS on current code (all records buffered → large delta) // PASSES after iterator implementation (lazy streaming → small delta) - assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB (streaming), " + "but was " + memoryDelta + " MB (all records buffered in memory)"); } @@ -362,11 +379,17 @@ void afterGroupSplitIteratorShouldRouteRecordsWithHasDataFor(OutputMode outputMo // Drain using hasDataFor — exercises the fix that skips unregistered pending records while (outputsHandler.hasMoreData()) { if (outputsHandler.hasDataFor("MAIN")) { - outputsHandler.getValue("MAIN"); + final row1Struct mainRow = outputsHandler.getValue("MAIN"); + Assertions.assertNotNull(mainRow, "MAIN record should not be null"); + Assertions.assertEquals("main-" + (mainCount * 2), mainRow.id, + "MAIN record id mismatch at index " + mainCount); mainCount++; } if (outputMode == OutputMode.TWO_OUTPUT && outputsHandler.hasDataFor("REJECT")) { - outputsHandler.getValue("REJECT"); + final row1Struct rejectRow = outputsHandler.getValue("REJECT"); + Assertions.assertNotNull(rejectRow, "REJECT record should not be null"); + Assertions.assertEquals("reject-" + (rejectCount * 2 + 1), rejectRow.id, + "REJECT record id mismatch at index " + rejectCount); rejectCount++; } } @@ -380,11 +403,17 @@ void afterGroupSplitIteratorShouldRouteRecordsWithHasDataFor(OutputMode outputMo if (outputMode != OutputMode.NO_OUTPUT) { while (outputsHandler.hasMoreData()) { if (outputsHandler.hasDataFor("MAIN")) { - outputsHandler.getValue("MAIN"); + final row1Struct mainRow = outputsHandler.getValue("MAIN"); + Assertions.assertNotNull(mainRow, "MAIN record should not be null"); + Assertions.assertEquals("main-" + (mainCount * 2), mainRow.id, + "MAIN record id mismatch at index " + mainCount); mainCount++; } if (outputMode == OutputMode.TWO_OUTPUT && outputsHandler.hasDataFor("REJECT")) { - outputsHandler.getValue("REJECT"); + final row1Struct rejectRow = outputsHandler.getValue("REJECT"); + Assertions.assertNotNull(rejectRow, "REJECT record should not be null"); + Assertions.assertEquals("reject-" + (rejectCount * 2 + 1), rejectRow.id, + "REJECT record id mismatch at index " + rejectCount); rejectCount++; } } @@ -404,23 +433,23 @@ void afterGroupSplitIteratorShouldRouteRecordsWithHasDataFor(OutputMode outputMo final int half = RECORD_COUNT / 2; switch (outputMode) { case TWO_OUTPUT: - org.junit.jupiter.api.Assertions.assertEquals(half, mainCount, + Assertions.assertEquals(half, mainCount, "MAIN should receive half the records (even indices)"); - org.junit.jupiter.api.Assertions.assertEquals(half, rejectCount, + Assertions.assertEquals(half, rejectCount, "REJECT should receive half the records (odd indices)"); break; case ONE_OUTPUT: - org.junit.jupiter.api.Assertions.assertEquals(half, mainCount, + Assertions.assertEquals(half, mainCount, "MAIN should receive half the records"); - org.junit.jupiter.api.Assertions.assertEquals(0, rejectCount, + Assertions.assertEquals(0, rejectCount, "REJECT not registered, should receive nothing"); break; case NO_OUTPUT: - org.junit.jupiter.api.Assertions.assertEquals(0, mainCount + rejectCount, + Assertions.assertEquals(0, mainCount + rejectCount, "No connections registered, no records should be received"); break; } - org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); chunkProcessor.stop(); @@ -499,12 +528,18 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering(OutputMode outputMode if (outputMode != OutputMode.NO_OUTPUT) { while (outputsHandler.hasMoreData()) { if (outputsHandler.hasDataFor("MAIN")) { - outputsHandler.getValue("MAIN"); + final row1Struct mainRow = outputsHandler.getValue("MAIN"); + Assertions.assertNotNull(mainRow, "MAIN record should not be null"); + Assertions.assertEquals("main-" + (mainCount * 2), mainRow.id, + "MAIN record id mismatch at index " + mainCount); mainCount++; } if (outputMode == OutputMode.TWO_OUTPUT) { if (outputsHandler.hasDataFor("REJECT")) { - outputsHandler.getValue("REJECT"); + final row1Struct rejectRow = outputsHandler.getValue("REJECT"); + Assertions.assertNotNull(rejectRow, "REJECT record should not be null"); + Assertions.assertEquals("reject-" + (rejectCount * 2 + 1), rejectRow.id, + "REJECT record id mismatch at index " + rejectCount); rejectCount++; } } @@ -525,25 +560,25 @@ void multiOutputIteratorShouldSplitRecordsWithoutBuffering(OutputMode outputMode switch (outputMode) { case TWO_OUTPUT: // Both connections registered: each receives exactly half - org.junit.jupiter.api.Assertions.assertEquals(expected, mainCount, + Assertions.assertEquals(expected, mainCount, "MAIN should receive half the records"); - org.junit.jupiter.api.Assertions.assertEquals(expected, rejectCount, + Assertions.assertEquals(expected, rejectCount, "REJECT should receive the other half"); break; case ONE_OUTPUT: // Only MAIN registered: MAIN gets its half, REJECT records are skipped - org.junit.jupiter.api.Assertions.assertEquals(expected, mainCount, + Assertions.assertEquals(expected, mainCount, "MAIN should receive half the records"); - org.junit.jupiter.api.Assertions.assertEquals(0, rejectCount, + Assertions.assertEquals(0, rejectCount, "REJECT not registered, should receive nothing"); break; case NO_OUTPUT: // No connections registered: nothing drained - org.junit.jupiter.api.Assertions.assertEquals(0, mainCount + rejectCount, + Assertions.assertEquals(0, mainCount + rejectCount, "No connections registered, no records should be received"); break; } - org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); chunkProcessor.stop(); @@ -604,11 +639,17 @@ void multiOutputIteratorIndependentModeShouldStreamPerConnection(OutputMode outp if (outputMode != OutputMode.NO_OUTPUT) { while (outputsHandler.hasMoreData()) { if (outputsHandler.hasDataFor("MAIN")) { - outputsHandler.getValue("MAIN"); + final row1Struct mainRow = outputsHandler.getValue("MAIN"); + Assertions.assertNotNull(mainRow, "MAIN record should not be null"); + Assertions.assertEquals("main-" + mainCount, mainRow.id, + "MAIN record id mismatch at index " + mainCount); mainCount++; } if (outputMode == OutputMode.TWO_OUTPUT && outputsHandler.hasDataFor("REJECT")) { - outputsHandler.getValue("REJECT"); + final row1Struct rejectRow = outputsHandler.getValue("REJECT"); + Assertions.assertNotNull(rejectRow, "REJECT record should not be null"); + Assertions.assertEquals("reject-" + rejectCount, rejectRow.id, + "REJECT record id mismatch at index " + rejectCount); rejectCount++; } } @@ -626,23 +667,23 @@ void multiOutputIteratorIndependentModeShouldStreamPerConnection(OutputMode outp switch (outputMode) { case TWO_OUTPUT: - org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT, mainCount, + Assertions.assertEquals(RECORD_COUNT, mainCount, "MAIN should receive all records"); - org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT / 2, rejectCount, + Assertions.assertEquals(RECORD_COUNT / 2, rejectCount, "REJECT should receive half the records"); break; case ONE_OUTPUT: - org.junit.jupiter.api.Assertions.assertEquals(RECORD_COUNT, mainCount, + Assertions.assertEquals(RECORD_COUNT, mainCount, "MAIN should receive all records"); - org.junit.jupiter.api.Assertions.assertEquals(0, rejectCount, + Assertions.assertEquals(0, rejectCount, "REJECT not registered, should receive nothing"); break; case NO_OUTPUT: - org.junit.jupiter.api.Assertions.assertEquals(0, mainCount + rejectCount, + Assertions.assertEquals(0, mainCount + rejectCount, "No connections registered, no records should be received"); break; } - org.junit.jupiter.api.Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, + Assertions.assertTrue(memoryDelta <= MAX_MEMORY_DELTA_MB, "Memory delta should be <= " + MAX_MEMORY_DELTA_MB + " MB, was " + memoryDelta + " MB"); chunkProcessor.stop(); From df17146a67440b0422d5501e85ba8f42dabfc8b2 Mon Sep 17 00:00:00 2001 From: wwang Date: Fri, 17 Jul 2026 12:23:02 +0800 Subject: [PATCH 19/21] feat: update Output annotation to support multiple values and adjust related processing logic --- .../java/org/talend/sdk/component/api/processor/Output.java | 2 +- .../design/extension/flows/ProcessorFlowsFactory.java | 2 +- .../talend/sdk/component/runtime/output/ProcessorImpl.java | 4 ++-- .../sdk/component/runtime/di/schema/TaCoKitGuessSchema.java | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java b/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java index a6fde9f1e70b2..6d1ea3a7ae3f5 100644 --- a/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java +++ b/component-api/src/main/java/org/talend/sdk/component/api/processor/Output.java @@ -25,5 +25,5 @@ @Retention(RUNTIME) public @interface Output { - String value() default "__default__"; + String[] value() default { "__default__" }; } diff --git a/component-runtime-design-extension/src/main/java/org/talend/sdk/component/design/extension/flows/ProcessorFlowsFactory.java b/component-runtime-design-extension/src/main/java/org/talend/sdk/component/design/extension/flows/ProcessorFlowsFactory.java index 37ddce11cf0b8..bff51ecf522a3 100644 --- a/component-runtime-design-extension/src/main/java/org/talend/sdk/component/design/extension/flows/ProcessorFlowsFactory.java +++ b/component-runtime-design-extension/src/main/java/org/talend/sdk/component/design/extension/flows/ProcessorFlowsFactory.java @@ -70,7 +70,7 @@ private Optional getAfterGroup() { private Stream getOutputParameters(final Method listener) { return of(listener.getParameters()) .filter(p -> p.isAnnotationPresent(Output.class)) - .map(p -> p.getAnnotation(Output.class).value()); + .flatMap(p -> of(p.getAnnotation(Output.class).value())); } private Stream getReturnedBranches(final Method listener) { diff --git a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java index 1ee55208ab721..fa08b2a7846e1 100644 --- a/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java +++ b/component-runtime-impl/src/main/java/org/talend/sdk/component/runtime/output/ProcessorImpl.java @@ -151,10 +151,10 @@ public void beforeGroup() { private BiFunction buildProcessParamBuilder(final Parameter parameter) { if (parameter.isAnnotationPresent(Output.class)) { - final String name = parameter.getAnnotation(Output.class).value(); if (MultiOutputIterator.class == parameter.getType()) { return (inputs, outputs) -> outputs.createMultiOutputIterator(); } + final String name = parameter.getAnnotation(Output.class).value()[0]; return (inputs, outputs) -> outputs.create(name); } @@ -169,10 +169,10 @@ private Function toOutputParamBuilder(final Parameter par if (parameter.isAnnotationPresent(LastGroup.class)) { return false; } - final String name = parameter.getAnnotation(Output.class).value(); if (MultiOutputIterator.class == parameter.getType()) { return outputs.createMultiOutputIterator(); } + final String name = parameter.getAnnotation(Output.class).value()[0]; return outputs.create(name); }; } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/schema/TaCoKitGuessSchema.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/schema/TaCoKitGuessSchema.java index 7dad572ad4202..e9bf41d0246f7 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/schema/TaCoKitGuessSchema.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/schema/TaCoKitGuessSchema.java @@ -776,7 +776,8 @@ public void fromOutputEmitterPojo(final Processor processor, final String outBra .flatMap(m -> IntStream .range(0, m.getParameterCount()) .filter(i -> m.getParameters()[i].isAnnotationPresent(Output.class) - && outBranchName.equals(m.getParameters()[i].getAnnotation(Output.class).value())) + && java.util.Arrays.asList(m.getParameters()[i].getAnnotation(Output.class).value()) + .contains(outBranchName)) .mapToObj(i -> m.getGenericParameterTypes()[i]) .filter(t -> t instanceof ParameterizedType parameterizedType && parameterizedType.getRawType() == OutputEmitter.class From 6cc661dcd9dad85d12a7788f7f25c7133045386b Mon Sep 17 00:00:00 2001 From: wwang Date: Fri, 17 Jul 2026 12:26:56 +0800 Subject: [PATCH 20/21] feat: update Output annotations in ProcessorBufferingTest to support multiple output values --- .../runtime/di/studio/ProcessorBufferingTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java index ea6007453833e..53c426d10b160 100644 --- a/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java +++ b/component-studio/component-runtime-di/src/test/java/org/talend/sdk/component/runtime/di/studio/ProcessorBufferingTest.java @@ -700,7 +700,7 @@ public static class HeavyEmitterProcessor implements Serializable { @ElementListener public void onElement(@Input final Record input, - @Output final MultiOutputIterator output) { + @Output("FLOW") final MultiOutputIterator output) { output.setIterator("FLOW", new java.util.Iterator<>() { private int index = 0; @@ -744,7 +744,7 @@ public void onElement(@Input final Record input) { } @AfterGroup - public void afterGroup(@Output final MultiOutputIterator out, + public void afterGroup(@Output({ "MAIN", "REJECT" }) final MultiOutputIterator out, @LastGroup final boolean lastGroup) { if (!lastGroup) { return; @@ -820,7 +820,7 @@ public void onElement(@Input final Record input) { } @AfterGroup - public void afterGroup(@Output final MultiOutputIterator out, + public void afterGroup(@Output({ "MAIN", "REJECT" }) final MultiOutputIterator out, @LastGroup final boolean lastGroup) { if (!lastGroup) { return; @@ -882,7 +882,7 @@ public static class SplitEmitterProcessor implements Serializable { @ElementListener public void onElement(@Input final Record input, - @Output final MultiOutputIterator splitter) { + @Output({ "MAIN", "REJECT" }) final MultiOutputIterator splitter) { splitter.setIterator(new java.util.Iterator<>() { private int index = 0; @@ -917,7 +917,7 @@ public static class IndependentEmitterProcessor implements Serializable { @ElementListener public void onElement(@Input final Record input, - @Output final MultiOutputIterator out) { + @Output({ "MAIN", "REJECT" }) final MultiOutputIterator out) { out.setIterator("MAIN", new java.util.Iterator<>() { private int index = 0; From 94ee246a05f7c66e3ec7fee7c2c68e9151c51657 Mon Sep 17 00:00:00 2001 From: wwang Date: Mon, 20 Jul 2026 11:55:21 +0800 Subject: [PATCH 21/21] refactor: simplify IO handling by removing caching and optimizing iteration logic --- .../component/runtime/di/BaseIOHandler.java | 61 +++---------------- .../component/runtime/di/OutputsHandler.java | 5 +- 2 files changed, 11 insertions(+), 55 deletions(-) diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java index d4dd1ccfed25e..136e3c7a4532b 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/BaseIOHandler.java @@ -54,15 +54,6 @@ protected interface TaggedAdvancer { boolean advance(); } - /** - * Flat array of all registered {@link IO} values. - * Cached after the first call to {@link #ioArray()} and invalidated whenever - * {@link #addConnection} or {@link #init} modifies {@link #connections}. - * Using an array in the hot drain loop avoids creating a new {@link java.util.Iterator} - * on each {@link #hasMoreData()} invocation. - */ - private IO[] ioCache; - /** * Tagged-source advancer for split streaming. * Non-null only when the component used a @@ -97,27 +88,16 @@ public void init(final Collection branchesOrder) { } if (!mapping.isEmpty()) { mapping.forEach((row, branch) -> connections.putIfAbsent(branch, connections.get(row))); - ioCache = null; } } public void addConnection(final String connectorName, final Class type) { connections.put(connectorName, new IO<>(type)); - ioCache = null; - } - - /** Returns a cached flat array of all registered IO objects for allocation-free iteration. */ - @SuppressWarnings("unchecked") - private IO[] ioArray() { - if (ioCache == null) { - ioCache = connections.values().toArray(IO[]::new); - } - return ioCache; } public void reset() { - for (final IO io : ioArray()) { - io.reset(); + for (IO value : connections.values()) { + value.reset(); } taggedSource = null; pendingOutputName = null; @@ -141,8 +121,8 @@ public boolean hasMoreData() { if (taggedSource != null) { return pendingOutputName != null || taggedSource.advance(); } - for (final IO io : ioArray()) { - if (io.hasNext()) { + for (IO value : connections.values()) { + if (value.hasNext()) { return true; } } @@ -248,10 +228,8 @@ protected String getActualName(final String name) { * Records are produced on-demand during the drain loop ({@link #hasNext()}/{@link #next()}). * Used by {@code MultiOutputIterator.setIterator(String, Iterator)} in the Studio DI runtime. * - * In pull mode, one record is eagerly buffered by {@link #hasNext()} so that - * subsequent calls to {@link #hasNext()} and {@link #next()} make no further - * calls to the source iterator — eliminating repeated {@code source.hasNext()} - * invocations across {@code hasMoreData()}, {@code hasDataFor()}, and {@code getValue()}. + * In pull mode, {@link #hasNext()} delegates directly to the source iterator's + * {@code hasNext()} — the source iterator is expected to be idempotent and fast. *

    * Note: The {@code source} field is only used by {@code OutputsHandler}; {@code InputsHandler} * uses only the queue-based push mode. @@ -265,51 +243,30 @@ static class IO { private Iterator source; - /** One-record look-ahead buffer for pull mode. Non-null iff {@link #bufferFull} is true. */ - private T buffered; - - /** True when {@link #buffered} holds a record ready to be returned by {@link #next()}. */ - private boolean bufferFull; - void setSource(final Iterator source) { closeSource(); this.source = source; - this.buffered = null; - this.bufferFull = false; } private void reset() { values.clear(); closeSource(); this.source = null; - this.buffered = null; - this.bufferFull = false; } boolean hasNext() { if (!values.isEmpty()) { return true; } - if (bufferFull) { - return true; - } - if (source != null && source.hasNext()) { - buffered = type.cast(source.next()); - bufferFull = true; - return true; - } - return false; + return source != null && source.hasNext(); } T next() { if (!values.isEmpty()) { return type.cast(values.poll()); } - if (bufferFull) { - final T val = buffered; - buffered = null; - bufferFull = false; - return val; + if (source != null && source.hasNext()) { + return type.cast(source.next()); } return null; } diff --git a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java index b3bacbc1b7d3c..b8de1ad38ee4e 100644 --- a/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java +++ b/component-studio/component-runtime-di/src/main/java/org/talend/sdk/component/runtime/di/OutputsHandler.java @@ -60,7 +60,7 @@ public void setIterator(final Iterator> iterator) { return false; } final TaggedOutput tagged = iterator.next(); - final String name = getActualName(tagged.getOutputName()); + final String name = tagged.getOutputName(); final BaseIOHandler.IO ref = connections.get(name); setPending(name, ref != null ? convert(tagged.getRecord(), ref) : tagged.getRecord()); @@ -70,8 +70,7 @@ public void setIterator(final Iterator> iterator) { @Override public void setIterator(final String outputName, final Iterator iterator) { - final String name = getActualName(outputName); - final BaseIOHandler.IO ref = connections.get(name); + final BaseIOHandler.IO ref = connections.get(outputName); if (ref == null) { return; }