CatalogProductSearchService.java

package com.ecommerce.catalog.application.service;

import com.ecommerce.catalog.application.exception.CatalogError;
import com.ecommerce.catalog.application.exception.CatalogException;
import com.ecommerce.catalog.application.model.CatalogModels.ProductSummary;
import com.ecommerce.catalog.application.model.SearchModels.ProductSearchPage;
import com.ecommerce.catalog.application.port.ProductSearchIndex;
import com.ecommerce.catalog.application.port.ProductSearchIndex.SearchResult;
import com.ecommerce.catalog.infrastructure.search.CatalogSearchProperties;
import com.ecommerce.catalog.infrastructure.search.SearchIndexUnavailableException;
import com.ecommerce.platform.common.api.PageResponse;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CatalogProductSearchService {

    private static final long MAX_RESULT_WINDOW = 10_000;

    private final CatalogService catalogService;
    private final ProductSearchIndex index;
    private final CatalogSearchProperties properties;
    private final Counter indexRequests;
    private final Counter fallbackRequests;

    public CatalogProductSearchService(
            CatalogService catalogService,
            ProductSearchIndex index,
            CatalogSearchProperties properties,
            MeterRegistry registry) {
        this.catalogService = catalogService;
        this.index = index;
        this.properties = properties;
        this.indexRequests = Counter.builder("ecommerce.catalog.search.requests")
                .tag("source", "opensearch")
                .register(registry);
        this.fallbackRequests = Counter.builder("ecommerce.catalog.search.requests")
                .tag("source", "mysql_fallback")
                .register(registry);
    }

    public ProductSearchPage search(
            String query,
            long page,
            long size,
            Long categoryId) {
        String normalized = query.trim();
        int from = searchOffset(page, size);
        if (!properties.enabled()) {
            return fallback(normalized, page, size, categoryId);
        }
        try {
            SearchResult result = index.search(normalized, categoryId, from, Math.toIntExact(size));
            List<ProductSummary> authoritative = catalogService.listActiveProductSummaries(result.productIds());
            indexRequests.increment();
            return new ProductSearchPage(
                    authoritative,
                    page,
                    size,
                    result.total(),
                    "OPENSEARCH",
                    false);
        } catch (SearchIndexUnavailableException exception) {
            if (!properties.mysqlFallbackEnabled()) {
                throw new CatalogException(CatalogError.SEARCH_INDEX_UNAVAILABLE, exception);
            }
            return fallback(normalized, page, size, categoryId);
        }
    }

    private int searchOffset(long page, long size) {
        try {
            long offset = Math.multiplyExact(page - 1, size);
            long endExclusive = Math.addExact(offset, size);
            if (endExclusive > MAX_RESULT_WINDOW) {
                throw new IllegalArgumentException(
                        "Search result window cannot exceed " + MAX_RESULT_WINDOW + " items");
            }
            return Math.toIntExact(offset);
        } catch (ArithmeticException exception) {
            throw new IllegalArgumentException("Search result window is too large", exception);
        }
    }

    private ProductSearchPage fallback(
            String query,
            long page,
            long size,
            Long categoryId) {
        if (!properties.mysqlFallbackEnabled()) {
            throw new CatalogException(CatalogError.SEARCH_INDEX_UNAVAILABLE);
        }
        PageResponse<ProductSummary> result = catalogService.listProducts(page, size, categoryId, query);
        fallbackRequests.increment();
        return new ProductSearchPage(
                result.items(),
                page,
                size,
                result.total(),
                "MYSQL_FALLBACK",
                true);
    }
}