From f50928fef1d6c762ab255e2bc82da86d0d5625dd Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:25:31 -0600 Subject: [PATCH] perf: azure deployment optimizations --- .github/workflows/deploy.yml | 62 ++++++++++++++++--- backend/Dockerfile | 2 +- backend/pom.xml | 10 +++ .../petshop/backend/BackendApplication.java | 2 + .../petshop/backend/config/CacheConfig.java | 24 +++++++ .../backend/controller/AuthController.java | 6 +- .../security/JwtAuthenticationFilter.java | 47 +++++++++----- .../security/UserAuthCacheService.java | 29 +++++++++ .../backend/service/ActivityLogService.java | 3 + .../backend/service/PasswordResetService.java | 7 ++- .../petshop/backend/service/UserService.java | 6 +- backend/src/main/resources/application.yml | 8 +++ 12 files changed, 177 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/config/CacheConfig.java create mode 100644 backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42c41a71..6bcb3472 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,21 +9,53 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - build-and-deploy: + build-backend: runs-on: ubuntu-latest permissions: contents: read packages: write - id-token: write steps: - name: Checkout uses: actions/checkout@v4.2.2 - - name: Set image names (lowercase) + - name: Set image name (lowercase) run: | OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3.3.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: ./backend + push: true + tags: ${{ env.BACKEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + build-frontend: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Set image name (lowercase) + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV - name: Log in to GitHub Container Registry @@ -33,12 +65,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push backend image - uses: docker/build-push-action@v6 - with: - context: ./backend - push: true - tags: ${{ env.BACKEND_IMAGE }}:latest + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Build and push frontend image uses: docker/build-push-action@v6 @@ -48,6 +76,22 @@ jobs: tags: ${{ env.FRONTEND_IMAGE }}:latest build-args: | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + runs-on: ubuntu-latest + needs: [build-backend, build-frontend] + permissions: + contents: read + id-token: write + + steps: + - name: Set image names (lowercase) + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV + echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV - name: Log in to Azure uses: azure/login@v2 diff --git a/backend/Dockerfile b/backend/Dockerfile index ed3552be..e868cdd6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,4 +10,4 @@ FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java","-jar","app.jar"] +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml index a87811c2..ad292bdb 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,16 @@ spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-cache + + + + com.github.ben-manes.caffeine + caffeine + + org.springframework.boot spring-boot-starter-validation diff --git a/backend/src/main/java/com/petshop/backend/BackendApplication.java b/backend/src/main/java/com/petshop/backend/BackendApplication.java index f933eb9d..26d18f3c 100644 --- a/backend/src/main/java/com/petshop/backend/BackendApplication.java +++ b/backend/src/main/java/com/petshop/backend/BackendApplication.java @@ -4,10 +4,12 @@ import com.petshop.backend.config.FlywayContextInitializer; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableAsync @EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/petshop/backend/config/CacheConfig.java b/backend/src/main/java/com/petshop/backend/config/CacheConfig.java new file mode 100644 index 00000000..4a8bc6b0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/config/CacheConfig.java @@ -0,0 +1,24 @@ +package com.petshop.backend.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager("userAuthCache"); + manager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(60, TimeUnit.SECONDS) + .maximumSize(1000)); + return manager; + } +} diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index e278e206..1b4ed0bf 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.JwtUtil; +import com.petshop.backend.security.UserAuthCacheService; import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.EmailService; @@ -58,8 +59,9 @@ public class AuthController { private final ActivityLogService activityLogService; private final PasswordResetService passwordResetService; private final EmailService emailService; + private final UserAuthCacheService userAuthCacheService; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; @@ -68,6 +70,7 @@ public class AuthController { this.activityLogService = activityLogService; this.passwordResetService = passwordResetService; this.emailService = emailService; + this.userAuthCacheService = userAuthCacheService; } @PostMapping("/register") @@ -263,6 +266,7 @@ public class AuthController { error.put("message", "Username, email, or phone already exists"); return ResponseEntity.status(HttpStatus.CONFLICT).body(error); } + userAuthCacheService.evict(updatedUser.getId()); return ResponseEntity.ok(toUserInfoResponse(updatedUser)); } diff --git a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java index a4caaaef..db7d47fc 100644 --- a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java @@ -2,7 +2,6 @@ package com.petshop.backend.security; import com.petshop.backend.entity.User; import com.petshop.backend.exception.ApiErrorResponder; -import com.petshop.backend.repository.UserRepository; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -16,16 +15,18 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Date; + @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; - private final UserRepository userRepository; + private final UserAuthCacheService userAuthCacheService; private final ApiErrorResponder apiErrorResponder; - public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) { + public JwtAuthenticationFilter(JwtUtil jwtUtil, UserAuthCacheService userAuthCacheService, ApiErrorResponder apiErrorResponder) { this.jwtUtil = jwtUtil; - this.userRepository = userRepository; + this.userAuthCacheService = userAuthCacheService; this.apiErrorResponder = apiErrorResponder; } @@ -44,30 +45,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { jwt = authHeader.substring(7); Long userId; + String username; + String roleStr; + Integer jwtTokenVersion; try { userId = jwtUtil.extractUserId(jwt); + username = jwtUtil.extractUsername(jwt); + roleStr = jwtUtil.extractRole(jwt); + jwtTokenVersion = jwtUtil.extractTokenVersion(jwt); } catch (JwtException | IllegalArgumentException ex) { writeUnauthorized(request, response, "Invalid or expired token", ex); return; } if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { - User user = userRepository.findById(userId).orElse(null); - if (user == null || user.getActive() == null || !user.getActive()) { - writeUnauthorized(request, response, "User account is inactive", null); - return; - } - if (!jwtUtil.validateToken(jwt, user)) { + if (jwtUtil.extractExpiration(jwt).before(new Date())) { writeUnauthorized(request, response, "Invalid or expired token", null); return; } - AppPrincipal principal = new AppPrincipal( - user.getId(), - user.getUsername(), - user.getRole(), - user.getTokenVersion() - ); + UserAuthCacheService.UserAuthData authData = userAuthCacheService.loadAuthData(userId); + if (authData == null || !Boolean.TRUE.equals(authData.active())) { + writeUnauthorized(request, response, "User account is inactive", null); + return; + } + if (!authData.tokenVersion().equals(jwtTokenVersion)) { + writeUnauthorized(request, response, "Invalid or expired token", null); + return; + } + + User.Role role; + try { + role = User.Role.valueOf(roleStr); + } catch (IllegalArgumentException ex) { + writeUnauthorized(request, response, "Invalid or expired token", ex); + return; + } + + AppPrincipal principal = new AppPrincipal(userId, username, role, jwtTokenVersion); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( principal, null, diff --git a/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java b/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java new file mode 100644 index 00000000..69d39a9d --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java @@ -0,0 +1,29 @@ +package com.petshop.backend.security; + +import com.petshop.backend.repository.UserRepository; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class UserAuthCacheService { + + private final UserRepository userRepository; + + public UserAuthCacheService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public record UserAuthData(Boolean active, Integer tokenVersion) {} + + @Cacheable(value = "userAuthCache", key = "#userId") + public UserAuthData loadAuthData(Long userId) { + return userRepository.findById(userId) + .map(u -> new UserAuthData(u.getActive(), u.getTokenVersion())) + .orElse(null); + } + + @CacheEvict(value = "userAuthCache", key = "#userId") + public void evict(Long userId) { + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java index 540b9383..c57378a8 100644 --- a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java +++ b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java @@ -5,6 +5,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service @@ -18,6 +19,7 @@ public class ActivityLogService { this.userRepository = userRepository; } + @Async public void record(Long userId, String activity) { if (userId == null || activity == null || activity.isBlank()) { return; @@ -36,6 +38,7 @@ public class ActivityLogService { } } + @Async public void record(User user, String activity) { if (user == null) { return; diff --git a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java index 2ecee374..f90cf6d5 100644 --- a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java @@ -7,6 +7,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.exception.BusinessException; import com.petshop.backend.repository.PasswordResetTokenRepository; import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.security.UserAuthCacheService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,16 +30,19 @@ public class PasswordResetService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; + private final UserAuthCacheService userAuthCacheService; private final SecureRandom secureRandom = new SecureRandom(); public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, UserRepository userRepository, PasswordEncoder passwordEncoder, - EmailService emailService) { + EmailService emailService, + UserAuthCacheService userAuthCacheService) { this.passwordResetTokenRepository = passwordResetTokenRepository; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.emailService = emailService; + this.userAuthCacheService = userAuthCacheService; } @Transactional @@ -97,6 +101,7 @@ public class PasswordResetService { user.setPassword(passwordEncoder.encode(newPassword)); user.setTokenVersion(user.getTokenVersion() + 1); userRepository.save(user); + userAuthCacheService.evict(user.getId()); token.setUsedAt(now); passwordResetTokenRepository.save(token); diff --git a/backend/src/main/java/com/petshop/backend/service/UserService.java b/backend/src/main/java/com/petshop/backend/service/UserService.java index 15a57c80..75966114 100644 --- a/backend/src/main/java/com/petshop/backend/service/UserService.java +++ b/backend/src/main/java/com/petshop/backend/service/UserService.java @@ -8,6 +8,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.security.UserAuthCacheService; import com.petshop.backend.util.AuthenticationHelper; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -33,11 +34,13 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final StoreRepository storeRepository; + private final UserAuthCacheService userAuthCacheService; - public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) { + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository, UserAuthCacheService userAuthCacheService) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.storeRepository = storeRepository; + this.userAuthCacheService = userAuthCacheService; } public Page getAllUsers(String query, String role, Pageable pageable) { @@ -147,6 +150,7 @@ public class UserService { } user = userRepository.save(user); + userAuthCacheService.evict(user.getId()); return mapToResponse(user); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 016775af..58312155 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -18,6 +18,10 @@ spring: username: ${SPRING_DATASOURCE_USERNAME:petshop} password: ${SPRING_DATASOURCE_PASSWORD:petshop} driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 sql: init: @@ -42,6 +46,10 @@ server: address: ${SERVER_ADDRESS:0.0.0.0} servlet: context-path: / + compression: + enabled: true + mime-types: application/json,application/javascript,text/css,text/plain + min-response-size: 1024 springdoc: api-docs: