From 7e1b33efdf4356fb9e97a6ba63f5474b1c8cbbf6 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 2 Dec 2025 17:46:51 -0500 Subject: [PATCH] added java caching and optimized controller queries --- README.md | 4 +- .../ballistic/BallisticApplication.java | 2 + .../ballistic/configuration/CacheConfig.java | 16 ++++ .../controllers/ProductController.java | 67 ++++++++++---- .../ballistic/repos/ProductRepository.java | 26 ++++++ .../impl/MerchantFeedImportServiceImpl.java | 87 ++++++++----------- 6 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/configuration/CacheConfig.java diff --git a/README.md b/README.md index 4e939ed..26ef0a4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Ballistic Backend -### Internal Engine for the Builder Ecosystem +# Ballistic Builder ( The Armory?) Backend +### Internal Engine for the Shadow System Armory? The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BallisticApplication.java index 611f7dc..e528833 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BallisticApplication.java @@ -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") diff --git a/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java new file mode 100644 index 0000000..e86d919 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java @@ -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"); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ProductController.java b/src/main/java/group/goforward/ballistic/controllers/ProductController.java index 1485be0..61feb24 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ProductController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ProductController.java @@ -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 getGunbuilderProducts( @RequestParam(defaultValue = "AR-15") String platform, @RequestParam(required = false, name = "partRoles") List 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 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 productIds = products.stream() .map(Product::getId) .toList(); List allOffers = productOfferRepository.findByProductIdIn(productIds); + long tOffersEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded offers: " + + allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); Map> 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 result = products.stream() .map(p -> { List 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 getOffersForProduct(@PathVariable("id") Integer productId) { List 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 offers) { diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index 3915542..ff601f1 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -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 { // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) List findByPlatformAndPartRoleIn(String platform, Collection 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 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 findByPlatformAndPartRoleInWithBrand( + @Param("platform") String platform, + @Param("partRoles") Collection partRoles + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index df36c69..c4440e0 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -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 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> fetchFeedRows(String feedUrl) { - System.out.println("OFFERS >>> reading offer feed from: " + feedUrl); - + log.info("Reading offer feed from {}", feedUrl); + List> 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 headers = new ArrayList<>(parser.getHeaderMap().keySet()); - + for (CSVRecord rec : parser) { Map 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 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 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> rows = fetchFeedRows(feedUrl); - + for (Map 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 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.