Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.openframe.api.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

/**
* Thread pool for the {@code deviceFilters} facet fan-out. The facet queries are blocking HTTP
* calls to the Pinot broker, so {@code supplyAsync} without an executor is the wrong default:
* on single-core tenant pods it spawns a new unpooled thread per task, and on larger hosts it
* would block the shared {@code ForkJoinPool.commonPool()}. A dedicated pool sized for
* concurrent round trips (not cores) gives reused, bounded threads; under saturation
* {@link ThreadPoolExecutor.CallerRunsPolicy} degrades to running on the request thread
* instead of rejecting.
*/
@Configuration
public class DeviceFilterExecutorConfig {

public static final String DEVICE_FILTER_FACET_EXECUTOR = "deviceFilterFacetExecutor";

@Bean(DEVICE_FILTER_FACET_EXECUTOR)
public Executor deviceFilterFacetExecutor(
@Value("${openframe.api.device-filters.facet-threads:6}") int threads,
@Value("${openframe.api.device-filters.facet-queue-capacity:64}") int queueCapacity) {

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(threads);
executor.setMaxPoolSize(threads);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("device-facet-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// Idle pods keep no threads: the facets are bursty (a dashboard or filter-UI load), and
// between bursts there is no reason to hold six parked threads in a 1Gi container.
executor.setAllowCoreThreadTimeOut(true);
executor.setKeepAliveSeconds(60);
executor.initialize();
return executor;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.openframe.api.dto.device;

import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;

/**
* One independently-computed piece of {@link DeviceFilters}.
*
* Every facet costs its own Pinot round trip, so a caller that needs one number should not
* pay for six. {@code DeviceFilterService} takes a set of these and skips the queries for
* everything outside it; the GraphQL data fetcher derives the set from the selection set, so
* {@code deviceFilters { filteredCount }} runs ONE query instead of six.
*
* {@link #graphQlField()} is the field name in the {@code DeviceFilters} GraphQL type and must
* stay in sync with {@code device.graphqls} — it is what the selection-set lookup matches on.
*/
public enum DeviceFilterFacet {

STATUSES("statuses"),
DEVICE_TYPES("deviceTypes"),
OS_TYPES("osTypes"),
ORGANIZATION_IDS("organizationIds"),
TAG_KEYS("tagKeys"),
FILTERED_COUNT("filteredCount");

/** Every facet — the behaviour non-GraphQL callers (the external REST API) still want. */
public static final Set<DeviceFilterFacet> ALL =
Collections.unmodifiableSet(EnumSet.allOf(DeviceFilterFacet.class));

private final String graphQlField;

DeviceFilterFacet(String graphQlField) {
this.graphQlField = graphQlField;
}

public String graphQlField() {
return graphQlField;
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
package com.openframe.api.service;

import com.openframe.api.config.DeviceFilterExecutorConfig;
import com.openframe.api.dto.device.DeviceFilterCriteria;
import com.openframe.api.dto.device.DeviceFilterFacet;
import com.openframe.api.dto.device.DeviceFilters;
import com.openframe.data.pinot.repository.PinotDeviceRepository;
import com.openframe.data.service.TenantIdProvider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;

import java.util.concurrent.Executor;
import java.util.function.Supplier;

import static com.openframe.api.dto.device.DeviceFilterFacet.DEVICE_TYPES;
import static com.openframe.api.dto.device.DeviceFilterFacet.FILTERED_COUNT;
import static com.openframe.api.dto.device.DeviceFilterFacet.ORGANIZATION_IDS;
import static com.openframe.api.dto.device.DeviceFilterFacet.OS_TYPES;
import static com.openframe.api.dto.device.DeviceFilterFacet.STATUSES;
import static com.openframe.api.dto.device.DeviceFilterFacet.TAG_KEYS;
import static java.util.Collections.emptyList;
import static java.util.concurrent.CompletableFuture.completedFuture;
import static java.util.concurrent.CompletableFuture.supplyAsync;

@Service
@Slf4j
Expand All @@ -21,16 +35,41 @@ public class DeviceFilterService {
private final PinotDeviceRepository pinotDeviceRepository;
private final DeviceFilterOptionMapper optionMapper;
private final TenantIdProvider tenantIdProvider;
private final Executor facetExecutor;

public DeviceFilterService(PinotDeviceRepository pinotDeviceRepository,
DeviceFilterOptionMapper optionMapper,
TenantIdProvider tenantIdProvider) {
TenantIdProvider tenantIdProvider,
@Qualifier(DeviceFilterExecutorConfig.DEVICE_FILTER_FACET_EXECUTOR)
Executor facetExecutor) {
this.pinotDeviceRepository = pinotDeviceRepository;
this.optionMapper = optionMapper;
this.tenantIdProvider = tenantIdProvider;
this.facetExecutor = facetExecutor;
}

/**
* Every facet. For callers with no selection set of their own — the external REST API, whose
* response DTO always carries all of them.
*/
public CompletableFuture<DeviceFilters> getDeviceFilters(DeviceFilterCriteria filters) {
return getDeviceFilters(filters, DeviceFilterFacet.ALL);
}

/**
* Only the requested facets; each one skipped is one Pinot round trip not made.
*
* Unrequested facets come back as empty lists (the mappers map {@code null} to
* {@code List.of()}), never {@code null}, so the non-null GraphQL list types hold even if a
* field is somehow read without having been selected. {@code filteredCount} is the exception —
* it is a scalar and stays {@code null} when unrequested, which is only observable if a caller
* asks for it without listing it in {@code requestedFacets}.
*
* @param requestedFacets facets to compute; {@code null} means all, EMPTY means none.
*/
public CompletableFuture<DeviceFilters> getDeviceFilters(DeviceFilterCriteria filters,
Set<DeviceFilterFacet> requestedFacets) {
Set<DeviceFilterFacet> facets = requestedFacets != null ? requestedFacets : DeviceFilterFacet.ALL;
List<String> statuses = filters != null && filters.getStatuses() != null ?
filters.getStatuses().stream().map(Enum::name).toList() : emptyList();
List<String> deviceTypes = filters != null && filters.getDeviceTypes() != null ?
Expand All @@ -42,17 +81,17 @@ public CompletableFuture<DeviceFilters> getDeviceFilters(DeviceFilterCriteria fi

String tenantId = tenantIdProvider.getTenantId();

CompletableFuture<Map<String, Integer>> statusesFuture = CompletableFuture.supplyAsync(() ->
CompletableFuture<Map<String, Integer>> statusesFuture = facetQuery(facets, STATUSES, () ->
pinotDeviceRepository.getStatusFilterOptions(tenantId, statuses, deviceTypes, osTypes, organizationIds, tagKeys, tagKeyValues));
CompletableFuture<Map<String, Integer>> deviceTypesFuture = CompletableFuture.supplyAsync(() ->
CompletableFuture<Map<String, Integer>> deviceTypesFuture = facetQuery(facets, DEVICE_TYPES, () ->
pinotDeviceRepository.getDeviceTypeFilterOptions(tenantId, statuses, deviceTypes, osTypes, organizationIds, tagKeys, tagKeyValues));
CompletableFuture<Map<String, Integer>> osTypesFuture = CompletableFuture.supplyAsync(() ->
CompletableFuture<Map<String, Integer>> osTypesFuture = facetQuery(facets, OS_TYPES, () ->
pinotDeviceRepository.getOsTypeFilterOptions(tenantId, statuses, deviceTypes, osTypes, organizationIds, tagKeys, tagKeyValues));
CompletableFuture<Map<String, Integer>> organizationsFuture = CompletableFuture.supplyAsync(() ->
CompletableFuture<Map<String, Integer>> organizationsFuture = facetQuery(facets, ORGANIZATION_IDS, () ->
pinotDeviceRepository.getOrganizationFilterOptions(tenantId, statuses, deviceTypes, osTypes, organizationIds, tagKeys, tagKeyValues));
CompletableFuture<Map<String, Integer>> tagKeysFuture = CompletableFuture.supplyAsync(() ->
CompletableFuture<Map<String, Integer>> tagKeysFuture = facetQuery(facets, TAG_KEYS, () ->
pinotDeviceRepository.getTagKeyFilterOptions(tenantId, statuses, deviceTypes, osTypes, organizationIds, tagKeys, tagKeyValues));
CompletableFuture<Integer> filteredCountFuture = CompletableFuture.supplyAsync(() ->
CompletableFuture<Integer> filteredCountFuture = facetQuery(facets, FILTERED_COUNT, () ->
pinotDeviceRepository.getFilteredDeviceCount(tenantId, statuses, deviceTypes, osTypes, organizationIds, tagKeys, tagKeyValues));

return CompletableFuture.allOf(
Expand All @@ -69,6 +108,21 @@ public CompletableFuture<DeviceFilters> getDeviceFilters(DeviceFilterCriteria fi
);
}

/**
* Submits one facet query, or short-circuits to {@code null} when the caller didn't ask for it.
*
* The executor is explicit on purpose — see {@link DeviceFilterExecutorConfig} for why the
* {@code supplyAsync} default is wrong for these blocking Pinot calls.
*/
private <T> CompletableFuture<T> facetQuery(Set<DeviceFilterFacet> facets,
DeviceFilterFacet facet,
Supplier<T> query) {
if (!facets.contains(facet)) {
return completedFuture(null);
}
return supplyAsync(query, facetExecutor);
}

/**
* Builds the tagKeyValues filter list for Pinot queries.
* Combines tagKeys and tagValues from DeviceFilterCriteria into "key:value" format.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.openframe.api.service;

import com.openframe.api.dto.device.DeviceFilterCriteria;
import com.openframe.api.dto.device.DeviceFilterFacet;
import com.openframe.api.dto.device.DeviceFilters;
import com.openframe.data.pinot.repository.PinotDeviceRepository;
import com.openframe.data.service.TenantIdProvider;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.util.EnumSet;
import java.util.Map;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

/**
* Each facet is one Pinot round trip, so what matters here is which repository methods are NOT
* called: the selection-set narrowing is the whole point of the second {@code getDeviceFilters}
* overload.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeviceFilterServiceTest {

private static final String TENANT = "tenant-1";

@Mock private PinotDeviceRepository pinotDeviceRepository;
@Mock private TenantIdProvider tenantIdProvider;
@Mock private com.openframe.data.repository.organization.OrganizationRepository organizationRepository;

private DeviceFilterService service() {
when(tenantIdProvider.getTenantId()).thenReturn(TENANT);
// Direct executor: the production pool is about wall-clock, not semantics, and running the
// facet queries inline keeps assertion order deterministic.
return new DeviceFilterService(
pinotDeviceRepository,
new DeviceFilterOptionMapper(organizationRepository),
tenantIdProvider,
Runnable::run);
}

@Test
void filteredCountOnly_runsOneQueryAndSkipsEveryFacet() {
when(pinotDeviceRepository.getFilteredDeviceCount(anyString(), any(), any(), any(), any(), any(), any()))
.thenReturn(7);

DeviceFilters result = service()
.getDeviceFilters(DeviceFilterCriteria.builder().build(), Set.of(DeviceFilterFacet.FILTERED_COUNT))
.join();

assertThat(result.getFilteredCount()).isEqualTo(7);
verify(pinotDeviceRepository).getFilteredDeviceCount(anyString(), any(), any(), any(), any(), any(), any());
verifyNoMoreInteractions(pinotDeviceRepository);
}

@Test
void unrequestedFacetsAreEmptyListsNotNull() {
DeviceFilters result = service()
.getDeviceFilters(DeviceFilterCriteria.builder().build(), Set.of(DeviceFilterFacet.FILTERED_COUNT))
.join();

// The GraphQL type declares these non-null, so an unselected facet must not surface as null
// if it is ever read.
assertThat(result.getStatuses()).isEmpty();
assertThat(result.getDeviceTypes()).isEmpty();
assertThat(result.getOsTypes()).isEmpty();
assertThat(result.getOrganizationIds()).isEmpty();
assertThat(result.getTagKeys()).isEmpty();
}

@Test
void dashboardCounterSelection_runsOnlyItsTwoQueries() {
when(pinotDeviceRepository.getStatusFilterOptions(anyString(), any(), any(), any(), any(), any(), any()))
.thenReturn(Map.of("ONLINE", 3));
when(pinotDeviceRepository.getFilteredDeviceCount(anyString(), any(), any(), any(), any(), any(), any()))
.thenReturn(3);

DeviceFilters result = service()
.getDeviceFilters(DeviceFilterCriteria.builder().build(),
EnumSet.of(DeviceFilterFacet.STATUSES, DeviceFilterFacet.FILTERED_COUNT))
.join();

assertThat(result.getStatuses()).hasSize(1);
assertThat(result.getFilteredCount()).isEqualTo(3);
verify(pinotDeviceRepository, never())
.getDeviceTypeFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository, never())
.getOsTypeFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository, never())
.getOrganizationFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository, never())
.getTagKeyFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
}

@Test
void nullFacetSet_keepsTheOldAllFacetsBehaviour() {
service().getDeviceFilters(DeviceFilterCriteria.builder().build(), null).join();

verify(pinotDeviceRepository).getStatusFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository).getDeviceTypeFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository).getOsTypeFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository).getOrganizationFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository).getTagKeyFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository).getFilteredDeviceCount(anyString(), any(), any(), any(), any(), any(), any());
}

@Test
void singleArgOverload_stillRequestsEveryFacet() {
service().getDeviceFilters(DeviceFilterCriteria.builder().build()).join();

verify(pinotDeviceRepository).getStatusFilterOptions(anyString(), any(), any(), any(), any(), any(), any());
verify(pinotDeviceRepository).getFilteredDeviceCount(anyString(), any(), any(), any(), any(), any(), any());
}
}
Loading
Loading