added java caching and optimized controller queries

This commit is contained in:
2025-12-02 17:46:51 -05:00
parent 009e512a66
commit 7e1b33efdf
6 changed files with 133 additions and 69 deletions

View File

@@ -5,8 +5,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
@ComponentScan("group.goforward.ballistic.controllers")
@ComponentScan("group.goforward.ballistic.repos")
@ComponentScan("group.goforward.ballistic.services")

View File

@@ -0,0 +1,16 @@
package group.goforward.ballistic.configuration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// Simple in-memory cache for dev/local
return new ConcurrentMapCacheManager("gunbuilderProducts");
}
}

View File

@@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.ProductOfferDto;
import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.web.dto.ProductSummaryDto;
import group.goforward.ballistic.web.mapper.ProductMapper;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@@ -30,35 +31,54 @@ public class ProductController {
}
@GetMapping("/gunbuilder")
@Cacheable(
value = "gunbuilderProducts",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
)
public List<ProductSummaryDto> getGunbuilderProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles
) {
// 1) Load products
long started = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: start, platform=" + platform +
", partRoles=" + (partRoles == null ? "null" : partRoles));
// 1) Load products (with brand pre-fetched)
long tProductsStart = System.currentTimeMillis();
List<Product> products;
if (partRoles == null || partRoles.isEmpty()) {
products = productRepository.findByPlatform(platform);
products = productRepository.findByPlatformWithBrand(platform);
} else {
products = productRepository.findByPlatformAndPartRoleIn(platform, partRoles);
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
}
long tProductsEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded products: " +
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
if (products.isEmpty()) {
long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
return List.of();
}
// 2) Load offers for these product IDs (Integer IDs)
// 2) Load offers for these product IDs
long tOffersStart = System.currentTimeMillis();
List<Integer> productIds = products.stream()
.map(Product::getId)
.toList();
List<ProductOffer> allOffers =
productOfferRepository.findByProductIdIn(productIds);
long tOffersEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded offers: " +
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
// 3) Map to DTOs with price and buyUrl
return products.stream()
long tMapStart = System.currentTimeMillis();
List<ProductSummaryDto> result = products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
@@ -71,25 +91,36 @@ public class ProductController {
return ProductMapper.toSummary(p, price, buyUrl);
})
.toList();
long tMapEnd = System.currentTimeMillis();
long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: mapping to DTOs took " +
(tMapEnd - tMapStart) + " ms");
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
"map=" + (tMapEnd - tMapStart) + " ms)");
return result;
}
@GetMapping("/{id}/offers")
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
return offers.stream()
.map(offer -> {
ProductOfferDto dto = new ProductOfferDto();
dto.setId(offer.getId().toString());
dto.setMerchantName(offer.getMerchant().getName());
dto.setPrice(offer.getEffectivePrice());
dto.setOriginalPrice(offer.getOriginalPrice());
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
dto.setBuyUrl(offer.getBuyUrl());
dto.setLastUpdated(offer.getLastSeenAt());
return dto;
})
.toList();
.map(offer -> {
ProductOfferDto dto = new ProductOfferDto();
dto.setId(offer.getId().toString());
dto.setMerchantName(offer.getMerchant().getName());
dto.setPrice(offer.getEffectivePrice());
dto.setOriginalPrice(offer.getOriginalPrice());
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
dto.setBuyUrl(offer.getBuyUrl());
dto.setLastUpdated(offer.getLastSeenAt());
return dto;
})
.toList();
}
private ProductOffer pickBestOffer(List<ProductOffer> offers) {

View File

@@ -3,6 +3,8 @@ package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.model.Brand;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
@@ -24,4 +26,28 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
// Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.)
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles);
// ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ----------
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
""")
List<Product> findByPlatformWithBrand(@Param("platform") String platform);
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.partRole IN :partRoles
AND p.deletedAt IS NULL
""")
List<Product> findByPlatformAndPartRoleInWithBrand(
@Param("platform") String platform,
@Param("partRoles") Collection<String> partRoles
);
}

View File

@@ -11,12 +11,15 @@ import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import org.springframework.cache.annotation.CacheEvict;
import group.goforward.ballistic.imports.MerchantFeedRow;
import group.goforward.ballistic.services.MerchantFeedImportService;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.Merchant;
@@ -36,6 +39,7 @@ import java.time.OffsetDateTime;
@Service
@Transactional
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
private final MerchantRepository merchantRepository;
private final BrandRepository brandRepository;
@@ -56,27 +60,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
@Override
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
public void importMerchantFeed(Integer merchantId) {
System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")");
log.info("Starting full import for merchantId={}", merchantId);
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// Read all rows from the merchant feed
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
System.out.println("IMPORT >>> read " + rows.size() + " rows for merchant=" + merchant.getName());
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
for (MerchantFeedRow row : rows) {
// Resolve brand from the row (fallback to "Aero Precision" or whatever you want as default)
Brand brand = resolveBrand(row);
Product p = upsertProduct(merchant, brand, row);
System.out.println("IMPORT >>> upserted product id=" + p.getId()
+ ", name=" + p.getName()
+ ", slug=" + p.getSlug()
+ ", platform=" + p.getPlatform()
+ ", partRole=" + p.getPartRole()
+ ", merchant=" + merchant.getName());
log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}",
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
}
}
@@ -85,9 +84,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
// ---------------------------------------------------------------------
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName()
+ ", sku=" + row.sku()
+ ", productName=" + row.productName());
log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName());
String mpn = trimOrNull(row.manufacturerId());
String upc = trimOrNull(row.sku()); // placeholder until real UPC field
@@ -109,9 +106,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setBrand(brand);
} else {
if (candidates.size() > 1) {
System.out.println("IMPORT !!! WARNING: multiple existing products found for brand="
+ brand.getName() + ", mpn=" + mpn + ", upc=" + upc
+ ". Using the first match (id=" + candidates.get(0).getId() + ")");
log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}",
brand.getName(), mpn, upc, candidates.get(0).getId());
}
p = candidates.get(0);
}
@@ -127,10 +123,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return saved;
}
private List<Map<String, String>> fetchFeedRows(String feedUrl) {
System.out.println("OFFERS >>> reading offer feed from: " + feedUrl);
log.info("Reading offer feed from {}", feedUrl);
List<Map<String, String>> rows = new ArrayList<>();
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8)
: java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8);
@@ -139,10 +135,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
.withIgnoreSurroundingSpaces()
.withTrim()
.parse(reader)) {
// capture header names from the CSV
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) {
Map<String, String> row = new HashMap<>();
for (String header : headers) {
@@ -153,8 +149,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
} catch (Exception ex) {
throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex);
}
System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows");
log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl);
return rows;
}
@@ -255,7 +251,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
String avantlinkProductId = trimOrNull(row.sku());
if (avantlinkProductId == null) {
// If there's truly no SKU, bail out we can't match this offer reliably.
System.out.println("IMPORT !!! skipping offer: no SKU for product id=" + product.getId());
log.debug("Skipping offer row with no SKU for product id={}", product.getId());
return;
}
@@ -358,11 +354,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
Map<String, Integer> headerMap = parser.getHeaderMap();
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
System.out.println(
"IMPORT >>> detected delimiter '" +
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
"' for feed: " + feedUrl
);
log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl);
return CSVFormat.DEFAULT.builder()
.setDelimiter(delimiter)
@@ -372,16 +364,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
.setTrim(true)
.build();
} else if (headerMap != null) {
System.out.println(
"IMPORT !!! delimiter '" +
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
"' produced headers: " + headerMap.keySet()
);
log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl);
}
} catch (Exception ex) {
lastException = ex;
System.out.println("IMPORT !!! error probing delimiter '" + delimiter +
"' for " + feedUrl + ": " + ex.getMessage());
log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage());
}
}
@@ -398,7 +385,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
String feedUrl = rawFeedUrl.trim();
System.out.println("IMPORT >>> reading feed for merchant=" + merchant.getName() + " from: " + feedUrl);
log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl);
List<MerchantFeedRow> rows = new ArrayList<>();
@@ -409,7 +396,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
try (Reader reader = openFeedReader(feedUrl);
CSVParser parser = new CSVParser(reader, format)) {
System.out.println("IMPORT >>> detected headers: " + parser.getHeaderMap().keySet());
log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) {
MerchantFeedRow row = new MerchantFeedRow(
@@ -447,7 +434,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
+ merchant.getName() + " from " + feedUrl, ex);
}
System.out.println("IMPORT >>> parsed " + rows.size() + " rows for merchant=" + merchant.getName());
log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName());
return rows;
}
@@ -474,7 +461,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
try {
return new BigDecimal(trimmed);
} catch (NumberFormatException ex) {
System.out.println("IMPORT !!! bad BigDecimal value: '" + raw + "', skipping");
log.debug("Skipping invalid numeric value '{}'", raw);
return null;
}
}
@@ -495,8 +482,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
try {
return rec.get(header);
} catch (IllegalArgumentException ex) {
System.out.println("IMPORT !!! short record #" + rec.getRecordNumber()
+ " missing column '" + header + "', treating as null");
log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header);
return null;
}
}
@@ -593,32 +579,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return "unknown";
}
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
public void syncOffersOnly(Integer merchantId) {
log.info("Starting offers-only sync for merchantId={}", merchantId);
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new RuntimeException("Merchant not found"));
if (Boolean.FALSE.equals(merchant.getIsActive())) {
return;
}
// Use offerFeedUrl if present, else fall back to feedUrl
String feedUrl = merchant.getOfferFeedUrl() != null
? merchant.getOfferFeedUrl()
: merchant.getFeedUrl();
if (feedUrl == null) {
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
}
List<Map<String, String>> rows = fetchFeedRows(feedUrl);
for (Map<String, String> row : rows) {
upsertOfferOnlyFromRow(merchant, row);
}
merchant.setLastOfferSyncAt(OffsetDateTime.now());
merchantRepository.save(merchant);
log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size());
}
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
// For the offer-only sync, we key offers by the same identifier we used when creating them.
// In the current AvantLink-style feed, that is the SKU column.