Repository: kestra-io/kestra
Issue: Raise an error when using the read function on Executor to ensure heavy processing is only performed by the Worker
Labels: enhancement

## Suggested implementation

Tasks that update the execution, e.g. the `Labels` task, are processed by the executor.
We can restrict the `read` function to only run on the Worker; this will raise an error when trying to use the `read` function in the `Labels` task with a friendly message pointing the user to process the labels in a different task e.g. in a `Return` task and pass to the `Labels` via outputs.

## Context

Examples of where the processing could be expensive if the file is large and should happen on the worker:

```yaml
    inputFiles:
      data.json: "{{ read(outputs.json.uri).split('\n') }}"

# and:
    labels:
      sites: "{{ read(outputs.latest_maps.outputFiles['siteCodes.json']) }}"
```

Full example:

```yaml
id: different_sitemap_consumer
namespace: neon_site_map
tasks:
  - id: consume
    type: io.kestra.plugin.kafka.Consume
    # disabled: false
    groupId: kestra-nonprodmap
    maxRecords: 1
    properties:
      auto.offset.reset: earliest
      bootstrap.servers: "{{ secret('KAFKA_HOST') }}"
      sasl.jaas.config: >-
        org.apache.kafka.common.security.scram.ScramLoginModule required 
        username="{{ secret('KAFKA_USER') }}" 
        password="{{ secret('KAFKA_PASSWORD') }}";
      sasl.mechanism: SCRAM-SHA-512
      security.protocol: SASL_PLAINTEXT
    topic: 
      - cper.event.cnc.map
      - abby.event.cnc.map
      - clbj.event.cnc.map
      - como.event.cnc.map
      - barr.event.cnc.map
      - bart.event.cnc.map
      - blue.event.cnc.map
      - cari.event.cnc.map
    valueDeserializer: JSON

  - id: label_msg_count
    type: io.kestra.core.tasks.executions.Labels
    labels:
      count: "Consumed {{outputs.consume.messagesCount}} message(s)"

  - id: empty_fail
    type: io.kestra.core.tasks.executions.Fail
    errorMessage: "Consumed no messages"
    condition: "{{ outputs.consume.messagesCount == 0 }}"

  - id: json
    type: io.kestra.plugin.serdes.json.JsonWriter
    from: "{{ outputs.consume.uri }}"

  - id: latest_maps
    type: io.kestra.plugin.scripts.node.Script
    inputFiles:
      data.json: "{{ read(outputs.json.uri).split('\n') }}"
    # beforeCommands:
    #  - npm i deep-object-diff
    script: |
      const fs = require('fs')
      const maps =  JSON.parse(fs.readFileSync('data.json', 'utf-8'))
      const lastMaps = maps.reduce((acc, curr) => {
        const map = JSON.parse(curr)
        acc[map.key] = map
        return acc
      }, (accMaps = {}, accMaps));
      //console.log(JSON.stringify(lastMaps))
      const output = Object.keys(lastMaps).reduce((acc, curr) => {
        acc += JSON.stringify(lastMaps[curr]) + "\n"
        return acc
      }, "");
      try {
        fs.writeFileSync("{{ outputDir }}/maps", output)
        fs.writeFileSync("{{ outputDir }}/siteCodes.json", JSON.stringify(Object.keys(lastMaps)))
      } catch (e) {
        console.error(e);
      }

  - id: label_site_codes
    type: io.kestra.core.tasks.executions.Labels
    labels:
      sites: "{{ read(outputs.latest_maps.outputFiles['siteCodes.json']) }}"
  
  - id: each_map
    type: io.kestra.core.tasks.flows.ForEachItem
    items: "{{ outputs.latest_maps.outputFiles['maps'] }}"
    batch:
      rows: 1
    namespace: neon_site_map
    flowId: site_map_processor
    wait: true 
    transmitFailed: true 
    inputs:
      map: "{{ taskrun.items }}" 
```

Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/core/src/main/java/io/kestra/core/runners/pebble/functions/ReadFileFunction.java b/core/src/main/java/io/kestra/core/runners/pebble/functions/ReadFileFunction.java
index 56267a6d59..aea875e827 100644
--- a/core/src/main/java/io/kestra/core/runners/pebble/functions/ReadFileFunction.java
+++ b/core/src/main/java/io/kestra/core/runners/pebble/functions/ReadFileFunction.java
@@ -3,6 +3,7 @@
 import io.kestra.core.storages.StorageContext;
 import io.kestra.core.storages.StorageInterface;
 import io.kestra.core.utils.Slugify;
+import io.micronaut.context.annotation.Value;
 import io.pebbletemplates.pebble.error.PebbleException;
 import io.pebbletemplates.pebble.extension.Function;
 import io.pebbletemplates.pebble.template.EvaluationContext;
@@ -25,6 +26,9 @@ public class ReadFileFunction implements Function {
     @Inject
     private StorageInterface storageInterface;
 
+    @Value("${kestra.server-type:}") // default to empty as tests didn't set this property
+    private String serverType;
+
     @Override
     public List<String> getArgumentNames() {
         return List.of("path");
@@ -32,6 +36,10 @@ public List<String> getArgumentNames() {
 
     @Override
     public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
+        if (!calledOnWorker()) {
+            throw new PebbleException(null, "The 'read' function can only be used in the Worker as it access the internal storage.", lineNumber, self.getName());
+        }
+
         if (!args.containsKey("path")) {
             throw new PebbleException(null, ERROR_MESSAGE, lineNumber, self.getName());
         }
@@ -45,6 +53,17 @@ public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationC
         }
     }
 
+    private boolean calledOnWorker() {
+        if ("WORKER".equals(serverType)) {
+            return true;
+        }
+        if ("STANDALONE".equals(serverType)) {
+            // check that it's called inside a worker thread
+            return Thread.currentThread().getClass().getName().equals("io.kestra.core.runners.Worker$WorkerThread");
+        }
+        return false;
+    }
+
     @SuppressWarnings("unchecked")
     private String readFromNamespaceFile(EvaluationContext context, String path) throws IOException {
         Map<String, String> flow = (Map<String, String>) context.getVariable("flow");


Test patch:
diff --git a/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java b/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java
index eb13abcb7e..262ce746f9 100644
--- a/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java
+++ b/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java
@@ -5,7 +5,9 @@
 import io.kestra.core.storages.StorageContext;
 import io.kestra.core.storages.StorageInterface;
 import io.kestra.core.utils.IdUtils;
+import io.micronaut.context.annotation.Property;
 import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import io.pebbletemplates.pebble.error.PebbleException;
 import jakarta.inject.Inject;
 import org.junit.jupiter.api.Test;
 
@@ -16,10 +18,12 @@
 import java.util.Map;
 
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.is;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
-@MicronautTest
+@MicronautTest(rebuildContext = true)
+@Property(name="kestra.server-type", value="WORKER")
 class ReadFileFunctionTest {
     @Inject
     VariableRenderer variableRenderer;
@@ -131,4 +135,11 @@ void readUnauthorizedInternalStorageFile() throws IOException {
         exception = assertThrows(IllegalArgumentException.class, () -> variableRenderer.render("{{ read('" + internalStorageFile + "') }}", triggerVariables));
         assertThat(exception.getMessage(), is("Unable to read the file '" + internalStorageFile + "' as it didn't belong to the current execution"));
     }
+
+    @Test
+    @Property(name="kestra.server-type", value="EXECUTOR")
+    void readFailOnNonWorkerNodes() {
+        IllegalVariableEvaluationException exception = assertThrows(IllegalVariableEvaluationException.class, () -> variableRenderer.render("{{ read('unknown.txt') }}", Map.of("flow", Map.of("namespace", "io.kestra.tests"))));
+        assertThat(exception.getMessage(), containsString("The 'read' function can only be used in the Worker as it access the internal storage."));
+    }
 }