Repository: kestra-io/kestra (java)

Issue:
[Execution filter] amend Namespace `contains` filter
### Describe the issue

Given the _Namespace_ filter contains both `starts with` and `contains`  operators the latter should behave differently from the former.

1. A manually entered value of the `contains` operator doesn't bring executions of a namespace containing such filter value.
2. The `contains` operator lists values namespaces which suits `starts with` more since the `contains` filter should operate on namespace "inner" parts (e.g. `team` -> `company.team.foo`).

---

# Example

## Flow

Define namespace `company.team.foo`:

```yaml
id: tapir_611734
namespace: company.team.foo

tasks:
  - id: hello
    type: io.kestra.plugin.core.log.Log
    message: Hello World! 🚀
```

## Filter

Running the develop via `./gradlew runLocal`.

Filter executions including the `company.team.foo` namespace using the `namespace contains team` filter:

http://localhost:8080/ui/executions?filters[namespace][CONTAINS]=team

### Environment

- Kestra Version: develop


Pull request:
fix(filters): use correct key for timeRange when using multiple filters
close #8498

Patch:
diff --git a/cli/src/main/resources/application.yml b/cli/src/main/resources/application.yml
index b9171f514..5daab8b03 100644
--- a/cli/src/main/resources/application.yml
+++ b/cli/src/main/resources/application.yml
@@ -202,6 +202,11 @@ kestra:
       initialDelay: 1m
       # The expected time between service heartbeats.
       heartbeatInterval: 3s
+    service:
+      purge:
+        initial-delay: 1h
+        fixed-delay: 1d
+        retention: 30d
   anonymous-usage-report:
     enabled: true
     uri: https://api.kestra.io/v1/reports/usages
diff --git a/core/build.gradle b/core/build.gradle
index 9fdc6d6ce..7dca0c73e 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -36,6 +36,7 @@ dependencies {
     implementation group: 'de.focus-shift', name: 'jollyday-jaxb'
     implementation 'nl.basjes.gitignore:gitignore-reader'
     implementation group: 'dev.failsafe', name: 'failsafe'
+    implementation 'com.github.ben-manes.caffeine:caffeine'
     api 'org.apache.httpcomponents.client5:httpclient5'
 
     // plugins
diff --git a/core/src/main/java/io/kestra/core/cache/NoopCache.java b/core/src/main/java/io/kestra/core/cache/NoopCache.java
new file mode 100644
index 000000000..afd9176e5
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/cache/NoopCache.java
@@ -0,0 +1,92 @@
+package io.kestra.core.cache;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Policy;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Function;
+
+/**
+ * A No-Op implementation of a Caffeine Cache.
+ * Useful to disable caching but still use a cache to avoid if/else chains
+ */
+public class NoopCache<K, V> implements Cache<K, V> {
+    private static final ConcurrentMap<?, ?> EMPTY_MAP = new ConcurrentHashMap<>(0);
+
+    @Override
+    public @Nullable V getIfPresent(K key) {
+        return null;
+    }
+
+    @Override
+    public V get(K key, Function<? super K, ? extends V> mappingFunction) {
+        return mappingFunction.apply(key);
+    }
+
+    @Override
+    public Map<K, @NonNull V> getAllPresent(Iterable<? extends K> keys) {
+        return Collections.emptyMap();
+    }
+
+    @Override
+    public Map<K, @NonNull V> getAll(Iterable<? extends K> keys, Function<? super Set<? extends K>, ? extends Map<? extends K, ? extends @NonNull V>> mappingFunction) {
+        return Collections.emptyMap();
+    }
+
+    @Override
+    public void put(K key, @NonNull V value) {
+        // just do nothing
+    }
+
+    @Override
+    public void putAll(Map<? extends K, ? extends @NonNull V> map) {
+        // just do nothing
+    }
+
+    @Override
+    public void invalidate(K key) {
+        // just do nothing
+    }
+
+    @Override
+    public void invalidateAll(Iterable<? extends K> keys) {
+        // just do nothing
+    }
+
+    @Override
+    public void invalidateAll() {
+        // just do nothing
+    }
+
+    @Override
+    public long estimatedSize() {
+        return 0;
+    }
+
+    @Override
+    public CacheStats stats() {
+        return CacheStats.empty();
+    }
+
+    @Override
+    public ConcurrentMap<K, @NonNull V> asMap() {
+        return (ConcurrentMap<K, V>) EMPTY_MAP;
+    }
+
+    @Override
+    public void cleanUp() {
+        // just do nothing
+    }
+
+    @Override
+    public Policy<K, @NonNull V> policy() {
+        throw new UnsupportedOperationException();
+    }
+}
diff --git a/core/src/main/java/io/kestra/core/repositories/ServiceInstanceRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/ServiceInstanceRepositoryInterface.java
index 778e6ac5c..5f13d4218 100644
--- a/core/src/main/java/io/kestra/core/repositories/ServiceInstanceRepositoryInterface.java
+++ b/core/src/main/java/io/kestra/core/repositories/ServiceInstanceRepositoryInterface.java
@@ -85,7 +85,14 @@ public interface ServiceInstanceRepositoryInterface {
                                                   final Instant to);
 
     /**
-     * Returns the function to be used for mapping column used to sort result.
+     * Purge all instances in the EMPTY state older than the until date.
+     *
+     * @return the number of purged instances
+     */
+    int purgeEmptyInstances(Instant until);
+
+    /**
+     * Returns the function to be used for mapping column used to sort results.
      *
      * @return the mapping function.
      */
diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepository.java
index 5f264292b..ab7d8f71b 100644
--- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepository.java
+++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepository.java
@@ -182,6 +182,16 @@ public abstract class AbstractJdbcServiceInstanceRepository extends AbstractJdbc
             this.jdbcRepository.fetch(query);
     }
 
+    @Override
+    public int purgeEmptyInstances(Instant until) {
+        return jdbcRepository.getDslContextWrapper().transactionResult(
+            configuration -> using(configuration).delete(table())
+                .where(STATE.eq(Service.ServiceState.EMPTY.name()))
+                .and(UPDATED_AT.lessOrEqual(until))
+                .execute()
+        );
+    }
+
     public void transaction(final TransactionalRunnable runnable) {
         this.jdbcRepository
             .getDslContextWrapper()
diff --git a/jdbc/src/main/java/io/kestra/jdbc/runner/JdbcServiceLivenessCoordinator.java b/jdbc/src/main/java/io/kestra/jdbc/runner/JdbcServiceLivenessCoordinator.java
index b38d6d732..a3811877a 100644
--- a/jdbc/src/main/java/io/kestra/jdbc/runner/JdbcServiceLivenessCoordinator.java
+++ b/jdbc/src/main/java/io/kestra/jdbc/runner/JdbcServiceLivenessCoordinator.java
@@ -10,11 +10,14 @@ import io.kestra.core.server.ServiceType;
 import io.kestra.core.server.WorkerTaskRestartStrategy;
 import io.kestra.jdbc.repository.AbstractJdbcServiceInstanceRepository;
 import io.micronaut.context.annotation.Requires;
+import io.micronaut.context.annotation.Value;
+import io.micronaut.scheduling.annotation.Scheduled;
 import jakarta.inject.Inject;
 import jakarta.inject.Singleton;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.time.Duration;
 import java.time.Instant;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
@@ -36,6 +39,7 @@ public final class JdbcServiceLivenessCoordinator extends AbstractServiceLivenes
 
     private final AtomicReference<JdbcExecutor> executor = new AtomicReference<>();
     private final AbstractJdbcServiceInstanceRepository serviceInstanceRepository;
+    private final Duration purgeRetention;
 
     /**
      * Creates a new {@link JdbcServiceLivenessCoordinator} instance.
@@ -46,9 +50,11 @@ public final class JdbcServiceLivenessCoordinator extends AbstractServiceLivenes
     @Inject
     public JdbcServiceLivenessCoordinator(final AbstractJdbcServiceInstanceRepository serviceInstanceRepository,
                                           final ServiceRegistry serviceRegistry,
-                                          final ServerConfig serverConfig) {
+                                          final ServerConfig serverConfig,
+                                          @Value("${kestra.server.service.purge.retention}") final Duration purgeRetention) {
         super(serviceInstanceRepository, serviceRegistry, serverConfig);
         this.serviceInstanceRepository = serviceInstanceRepository;
+        this.purgeRetention = purgeRetention;
     }
 
     /**
@@ -142,6 +148,12 @@ public final class JdbcServiceLivenessCoordinator extends AbstractServiceLivenes
         });
     }
 
+    @Scheduled(initialDelay = "${kestra.server.service.purge.initial-delay}", fixedDelay = "${kestra.server.service.purge.fixed-delay}")
+    public void purgeEmptyInstances() {
+        int purged = serviceInstanceRepository.purgeEmptyInstances(Instant.now().minus(purgeRetention));
+        log.info("Purged {} service instances", purged);
+    }
+
 
     synchronized void setExecutor(final JdbcExecutor executor) {
         this.executor.set(executor);
diff --git a/ui/src/components/admin/Triggers.vue b/ui/src/components/admin/Triggers.vue
index 9b1a1bf34..523153bf0 100644
--- a/ui/src/components/admin/Triggers.vue
+++ b/ui/src/components/admin/Triggers.vue
@@ -196,13 +196,13 @@
                                     </span>
 
                                     <el-button
+                                        :icon="CalendarCollapseHorizontalOutline"
                                         v-if="user.hasAnyAction(permission.EXECUTION, action.UPDATE)"
                                         @click="restart(scope.row)"
-                                        class="restartTrigger"
+                                        size="small"
+                                        type="primary"
                                     >
-                                        <kicon :tooltip="$t(`restart trigger.tooltip`)" placement="left">
-                                            <Restart />
-                                        </kicon>
+                                        {{ $t("backfill executions") }}
                                     </el-button>
                                 </div>
                             </template>
@@ -261,9 +261,9 @@
     import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
     import SelectTable from "../layout/SelectTable.vue";
     import BulkSelect from "../layout/BulkSelect.vue";
-    import Restart from "vue-material-design-icons/Restart.vue";
     import Cron from "../layout/Cron.vue"
     import TriggerAvatar from "../flows/TriggerAvatar.vue"
+    import CalendarCollapseHorizontalOutline from "vue-material-design-icons/CalendarCollapseHorizontalOutline.vue"
 </script>
 <script>
     import RouteContext from "../../mixins/routeContext";
diff --git a/ui/src/components/filter/KestraFilter.vue b/ui/src/components/filter/KestraFilter.vue
index 1c11c8884..60a4eb34c 100644
--- a/ui/src/components/filter/KestraFilter.vue
+++ b/ui/src/components/filter/KestraFilter.vue
@@ -324,7 +324,6 @@
             emits("input", getInputValue());
         }
 
-        console.log("getInputValue", getInputValue());
         if (getInputValue() === TEXT_PREFIX) {
             // cons
             select.value!.states.inputValue = "";
@@ -650,7 +649,7 @@
     const includedOptions = computed(() => {
         const dates = ["relative_date", "absolute_date"];
 
-        const found = currentFilters.value?.find((v) => dates.includes(v?.label));
+        const found = currentFilters.value?.find((v) => dates.includes(v?.field));
         const exclude = found ? dates.find((date) => date !== found.label) : null;
 
         return OPTIONS.filter((o) => {
diff --git a/ui/src/components/filter/utils/helpers.ts b/ui/src/components/filter/utils/helpers.ts
index 1b7a034a8..4da5d09db 100644
--- a/ui/src/components/filter/utils/helpers.ts
+++ b/ui/src/components/filter/utils/helpers.ts
@@ -122,12 +122,13 @@ export const encodeSearchParams = (filters, OPTIONS) => {
 
     return filters.reduce((query, filter) => {
         if(filter.operation) {
-            Object.assign(query, encode(filter.value, filter.field, filter.operation));
+            const match = OPTIONS.find((o) => o.value.label === filter.field);
+            const key = match ? match.key : filter.field === "text" ? "q" : filter.field;
+            Object.assign(query, encode(filter.value, key, filter.operation));
         } else {
             const match = OPTIONS.find((o) => o.value.label === filter.label);
             const key = match ? match.key : filter.label === "text" ? "q" : null;
             const operation = filter.comparator?.value || match?.comparators?.find(c => c.value === filter.operation)?.value || "EQUALS";
-
             if (key) {
                 if (key !== "date") {
                     Object.assign(query, encode(filter.value, key, operation));
@@ -164,7 +165,6 @@ export const decodeSearchParams = (query, include, OPTIONS) => {
             return {field: label, value: decodeURIComponent(value), operation: comparator.value};
         })
         .filter(Boolean);
-
     return params;
 };
 export const isSearchPath = (name: string) => ["home", "flows/list", "executions/list", "logs/list", "admin/triggers"].includes(name);
\ No newline at end of file


Test patch:
diff --git a/core/src/test/java/io/kestra/core/cache/NoopCacheTest.java b/core/src/test/java/io/kestra/core/cache/NoopCacheTest.java
new file mode 100644
index 000000000..66c7a1630
--- /dev/null
+++ b/core/src/test/java/io/kestra/core/cache/NoopCacheTest.java
@@ -0,0 +1,100 @@
+package io.kestra.core.cache;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+class NoopCacheTest {
+    private Cache<String, String> cache = new NoopCache<>();
+
+    @Test
+    void getIfPresent() {
+        cache.put("key", "value");
+
+        assertThat(cache.getIfPresent("key")).isNull();
+    }
+
+    @Test
+    void get() {
+        cache.put("key", "value");
+
+        assertThat(cache.get("key", k -> "value")).isEqualTo("value");
+    }
+
+    @Test
+    void getAllPresent() {
+        cache.put("key", "value");
+
+        assertThat(cache.getAllPresent(List.of("key"))).hasSize(0);
+    }
+
+    @Test
+    void getAll() {
+        cache.put("key", "value");
+
+        assertThat(cache.getAll(List.of("key"), it -> Map.of("key", "value"))).hasSize(0);
+    }
+
+    @Test
+    void putAll() {
+        cache.putAll(Map.of("key", "value"));
+
+        assertThat(cache.getAllPresent(List.of("key"))).hasSize(0);
+    }
+
+    @Test
+    void invalidate() {
+        cache.put("key", "value");
+        cache.invalidate("key");
+
+        assertThat(cache.getIfPresent("key")).isNull();
+    }
+
+    @Test
+    void invalidateAll() {
+        cache.putAll(Map.of("key", "value"));
+        cache.invalidateAll();
+
+        assertThat(cache.getIfPresent("key")).isNull();
+    }
+
+    @Test
+    void estimatedSize() {
+        cache.put("key", "value");
+
+        assertThat(cache.estimatedSize()).isEqualTo(0);
+    }
+
+    @Test
+    void stats() {
+        cache.put("key", "value");
+
+        assertThat(cache.stats()).isEqualTo(CacheStats.empty());
+    }
+
+    @Test
+    void asMap() {
+        cache.put("key", "value");
+
+        assertThat(cache.asMap()).hasSize(0);
+    }
+
+    @Test
+    void cleanUp() {
+        cache.put("key", "value");
+        cache.cleanUp();
+
+        assertThat(cache.getAllPresent(List.of("key"))).hasSize(0);
+    }
+
+    @Test
+    void policy() {
+        assertThrows(UnsupportedOperationException.class, () -> cache.policy());
+    }
+}
\ No newline at end of file
diff --git a/core/src/test/java/io/kestra/core/runners/AbstractRunnerTest.java b/core/src/test/java/io/kestra/core/runners/AbstractRunnerTest.java
index 6a549ada2..93ac44aa1 100644
--- a/core/src/test/java/io/kestra/core/runners/AbstractRunnerTest.java
+++ b/core/src/test/java/io/kestra/core/runners/AbstractRunnerTest.java
@@ -193,7 +193,7 @@ public abstract class AbstractRunnerTest {
         flowTriggerCaseTest.trigger();
     }
 
-    @Test
+    @RetryingTest(5) // flaky on CI but never fail locally
     @LoadFlows({"flows/valids/trigger-flow-listener-with-pause.yaml",
         "flows/valids/trigger-flow-with-pause.yaml"})
     void flowTriggerWithPause() throws Exception {
diff --git a/core/src/test/java/io/kestra/core/runners/ExecutionServiceTest.java b/core/src/test/java/io/kestra/core/runners/ExecutionServiceTest.java
index 98a62b865..eba0b93b1 100644
--- a/core/src/test/java/io/kestra/core/runners/ExecutionServiceTest.java
+++ b/core/src/test/java/io/kestra/core/runners/ExecutionServiceTest.java
@@ -392,8 +392,9 @@ class ExecutionServiceTest {
 
     @Test
     @ExecuteFlow("flows/valids/logs.yaml")
-    void deleteExecutionKeepLogs(Execution execution) throws IOException {
+    void deleteExecutionKeepLogs(Execution execution) throws IOException, TimeoutException {
         assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
+        Await.until(() -> logRepository.findByExecutionId(execution.getTenantId(), execution.getId(), Level.TRACE).size() == 5, Duration.ofMillis(10), Duration.ofSeconds(5));
 
         executionService.delete(execution, false, false, false);
 
diff --git a/jdbc-h2/src/test/resources/application-test.yml b/jdbc-h2/src/test/resources/application-test.yml
index 1b874c7d8..e809a316b 100644
--- a/jdbc-h2/src/test/resources/application-test.yml
+++ b/jdbc-h2/src/test/resources/application-test.yml
@@ -16,6 +16,14 @@ flyway:
 
 kestra:
   server-type: STANDALONE
+  server:
+    liveness:
+      enabled: false
+    service:
+      purge:
+        initial-delay: 1h
+        fixed-delay: 1d
+        retention: 30d
   queue:
     type: h2
   repository:
diff --git a/jdbc-mysql/src/test/resources/application-test.yml b/jdbc-mysql/src/test/resources/application-test.yml
index e5458ffae..3f0fbe562 100644
--- a/jdbc-mysql/src/test/resources/application-test.yml
+++ b/jdbc-mysql/src/test/resources/application-test.yml
@@ -17,6 +17,14 @@ flyway:
 
 kestra:
   server-type: STANDALONE
+  server:
+    liveness:
+      enabled: false
+    service:
+      purge:
+        initial-delay: 1h
+        fixed-delay: 1d
+        retention: 30d
   queue:
     type: mysql
   repository:
diff --git a/jdbc-postgres/src/test/resources/application-test.yml b/jdbc-postgres/src/test/resources/application-test.yml
index 8f40eab5e..687c6e6bd 100644
--- a/jdbc-postgres/src/test/resources/application-test.yml
+++ b/jdbc-postgres/src/test/resources/application-test.yml
@@ -39,4 +39,9 @@ kestra:
       allow-parameter-outputs: true
   server:
     liveness:
-      enabled: false
\ No newline at end of file
+      enabled: false
+    service:
+      purge:
+        initial-delay: 1h
+        fixed-delay: 1d
+        retention: 30d
\ No newline at end of file
diff --git a/jdbc/src/test/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepositoryTest.java b/jdbc/src/test/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepositoryTest.java
index 5c7d4f89f..b7a88e398 100644
--- a/jdbc/src/test/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepositoryTest.java
+++ b/jdbc/src/test/java/io/kestra/jdbc/repository/AbstractJdbcServiceInstanceRepositoryTest.java
@@ -168,6 +168,21 @@ public abstract class AbstractJdbcServiceInstanceRepositoryTest {
         Assertions.assertEquals(new ServiceStateTransition.Response(FAILED, instance), response);
     }
 
+    @Test
+    void shouldPurgeServiceInstance() {
+        // Given
+        ServiceInstance instance = Fixtures.RunningServiceInstance;
+        repository.update(instance);
+        instance = Fixtures.EmptyServiceInstance;
+        repository.update(instance);
+
+        // When
+        int purged = repository.purgeEmptyInstances(Instant.now());
+
+        //Then
+        assertThat(purged).isEqualTo(1);
+    }
+
     public static final class Fixtures {
 
         public static List<ServiceInstance> all() {
diff --git a/webserver/src/test/resources/application-test.yml b/webserver/src/test/resources/application-test.yml
index e7e58a906..8ee3fce26 100644
--- a/webserver/src/test/resources/application-test.yml
+++ b/webserver/src/test/resources/application-test.yml
@@ -55,6 +55,11 @@ kestra:
         - "/api/v1/executions/webhook/"
     liveness:
       enabled: false
+    service:
+      purge:
+        initial-delay: 1h
+        fixed-delay: 1d
+        retention: 30d
   queue:
     type: h2
   repository: