From 6b101df9b9ffae9d900be86633d90d6c944dc8df Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Tue, 25 Mar 2025 19:00:40 -0400 Subject: [PATCH 1/8] Added support for the application cache --- .../rest/SiteAppCacheRestController.java | 42 ++++++++++ ....java => SiteCacheRestControllerBase.java} | 44 +++++------ .../rest/SiteInternalCacheRestController.java | 78 +++++++++++++++++++ .../engine/event/SiteContextPurgedEvent.java | 5 ++ .../service/context/SiteContextFactory.java | 3 +- .../deployment/DeploymentEventsWatcher.java | 5 ++ .../engine/rendering/controller-context.xml | 14 +++- 7 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java rename src/main/java/org/craftercms/engine/controller/rest/{SiteCacheRestController.java => SiteCacheRestControllerBase.java} (64%) create mode 100644 src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java new file mode 100644 index 000000000..af96936b9 --- /dev/null +++ b/src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.craftercms.engine.controller.rest; + +import org.craftercms.core.controller.rest.CrafterRestController; +import org.craftercms.core.controller.rest.RestControllerBase; +import org.craftercms.core.service.CacheService; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.beans.ConstructorProperties; + +/** + * REST controller for operations related to a site's application cache. + * + * @author avasquez + */ +@CrafterRestController +@RequestMapping(RestControllerBase.REST_BASE_URI + SiteAppCacheRestController.URL_ROOT) +public class SiteAppCacheRestController extends SiteCacheRestControllerBase { + + public static final String URL_ROOT = "/site/app_cache"; + + @ConstructorProperties({"cacheService", "configuredToken"}) + public SiteAppCacheRestController(final CacheService cacheService, final String configuredToken) { + super(cacheService, configuredToken); + } + +} diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java similarity index 64% rename from src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java rename to src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java index 5a8b7762b..fed8338b8 100644 --- a/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java +++ b/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved. + * Copyright (C) 2007-2025 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by @@ -13,7 +13,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.craftercms.engine.controller.rest; import jakarta.servlet.http.HttpServletRequest; @@ -22,10 +21,8 @@ import org.apache.commons.logging.LogFactory; import org.craftercms.commons.exceptions.InvalidManagementTokenException; import org.craftercms.core.cache.CacheStatistics; -import org.craftercms.core.controller.rest.CrafterRestController; import org.craftercms.core.controller.rest.RestControllerBase; -import org.craftercms.engine.event.SiteContextCreatedEvent; -import org.craftercms.engine.event.SiteEvent; +import org.craftercms.core.service.CacheService; import org.craftercms.engine.service.context.SiteContext; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -37,41 +34,36 @@ import static java.lang.String.format; /** - * REST controller for operations related to a site's cache. + * Base class for site-related cache controllers. * - * @author Alfonso Vásquez + * @author avasquez */ -@CrafterRestController -@RequestMapping(RestControllerBase.REST_BASE_URI + SiteCacheRestController.URL_ROOT) -public class SiteCacheRestController extends RestControllerBase { +public class SiteCacheRestControllerBase extends RestControllerBase { - private static final Log logger = LogFactory.getLog(SiteCacheRestController.class); + private static final Log logger = LogFactory.getLog(SiteCacheRestControllerBase.class); - public static final String URL_ROOT = "/site/cache"; public static final String URL_CLEAR = "/clear"; public static final String URL_STATS = "/statistics"; - private final String configuredToken; + protected CacheService cacheService; + protected final String configuredToken; - @ConstructorProperties({"configuredToken"}) - public SiteCacheRestController(final String configuredToken) { + @ConstructorProperties({"cacheService", "configuredToken"}) + public SiteCacheRestControllerBase(final CacheService cacheService, final String configuredToken) { + this.cacheService = cacheService; this.configuredToken = configuredToken; } @RequestMapping(value = URL_CLEAR, method = RequestMethod.GET) public Map clear(HttpServletRequest request, @RequestParam String token) throws InvalidManagementTokenException { validateToken(token); + SiteContext siteContext = SiteContext.getCurrent(); String siteName = siteContext.getSiteName(); - String msg; - - // Don't clear cache if the context was just created in this request - if (SiteEvent.getLatestRequestEvent(SiteContextCreatedEvent.class, request) != null) { - return createResponseMessage(format("Site context for '%s' created during the request. Cache clear not necessary", siteName)); - } else { - siteContext.startCacheClear(); - msg = format("Cache clear for site '%s' started", siteName); - } + + cacheService.clearScope(siteContext.getContext()); + + String msg = format("Cache clear for site '%s' completed", siteName); logger.debug(msg); @@ -82,8 +74,7 @@ public Map clear(HttpServletRequest request, @RequestParam Strin public CacheStatistics getStatistics(@RequestParam String token) throws InvalidManagementTokenException { validateToken(token); - SiteContext siteContext = SiteContext.getCurrent(); - return siteContext.getCacheTemplate().getCacheService().getStatistics(siteContext.getContext()); + return cacheService.getStatistics(SiteContext.getCurrent().getContext()); } protected final void validateToken(final String requestToken) throws InvalidManagementTokenException { @@ -91,4 +82,5 @@ protected final void validateToken(final String requestToken) throws InvalidMana throw new InvalidManagementTokenException("Management authorization failed, invalid token."); } } + } diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java new file mode 100644 index 000000000..d1e5179e5 --- /dev/null +++ b/src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.craftercms.engine.controller.rest; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.craftercms.commons.exceptions.InvalidManagementTokenException; +import org.craftercms.core.controller.rest.CrafterRestController; +import org.craftercms.core.controller.rest.RestControllerBase; +import org.craftercms.core.service.CacheService; +import org.craftercms.engine.event.SiteContextCreatedEvent; +import org.craftercms.engine.event.SiteEvent; +import org.craftercms.engine.service.context.SiteContext; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +import java.beans.ConstructorProperties; +import java.util.Map; + +import static java.lang.String.format; + +/** + * REST controller for operations related to a site's internal cache. + * + * @author avasquez + */ +@CrafterRestController +@RequestMapping(RestControllerBase.REST_BASE_URI + SiteInternalCacheRestController.URL_ROOT) +public class SiteInternalCacheRestController extends SiteCacheRestControllerBase { + + private static final Log logger = LogFactory.getLog(SiteInternalCacheRestController.class); + + public static final String URL_ROOT = "/site/cache"; + + @ConstructorProperties({"cacheService", "configuredToken"}) + public SiteInternalCacheRestController(final CacheService cacheService, final String configuredToken) { + super(cacheService, configuredToken); + } + + @RequestMapping(value = URL_CLEAR, method = RequestMethod.GET) + public Map clear(HttpServletRequest request, @RequestParam String token) throws InvalidManagementTokenException { + validateToken(token); + + SiteContext siteContext = SiteContext.getCurrent(); + String siteName = siteContext.getSiteName(); + String msg; + + // Don't clear cache if the context was just created in this request + if (SiteEvent.getLatestRequestEvent(SiteContextCreatedEvent.class, request) != null) { + msg = format("Site context for '%s' created during the request. Cache clear not necessary", siteName); + } else { + siteContext.startCacheClear(); + + msg = format("Cache clear for site '%s' started", siteName); + } + + logger.debug(msg); + + return createResponseMessage(msg); + } + +} diff --git a/src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java b/src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java index 76f7a8441..36d4e0369 100644 --- a/src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java +++ b/src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java @@ -17,6 +17,11 @@ import org.craftercms.engine.service.context.SiteContext; +/** + * Event published when a {@link SiteContext} has been completely removed from the system. + * + * @author avasquez + */ public class SiteContextPurgedEvent extends SiteEvent { /** diff --git a/src/main/java/org/craftercms/engine/service/context/SiteContextFactory.java b/src/main/java/org/craftercms/engine/service/context/SiteContextFactory.java index f3e32c7a0..53b8649ca 100644 --- a/src/main/java/org/craftercms/engine/service/context/SiteContextFactory.java +++ b/src/main/java/org/craftercms/engine/service/context/SiteContextFactory.java @@ -283,7 +283,8 @@ public SiteContext createContext(String siteName) { configVariables.put(SITE_NAME_CONFIG_VARIABLE, siteName); configVariables.put(SITE_ID_CONFIG_VARIABLE, siteName); Context context = storeService.getContext(UUID.randomUUID().toString(), storeType, resolvedRootFolderPath, - mergingOn, cacheOn, maxAllowedItemsInCache, ignoreHiddenFiles, configVariables); + mergingOn, cacheOn, maxAllowedItemsInCache, ignoreHiddenFiles, + configVariables); try { SiteContext siteContext = new SiteContext(); diff --git a/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java b/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java index 10cd2bf51..a0db925b7 100644 --- a/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java +++ b/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java @@ -179,6 +179,11 @@ public void onApplicationEvent(ApplicationEvent event) { } } + @Override + public boolean supportsAsyncExecution() { + return false; + } + private long getLatestEventTimestamp(String siteName, Class eventClass) { SiteEvent event = latestSiteContextEvents.get(String.format(LATEST_EVENT_KEY_FORMAT, siteName, eventClass)); if (event != null) { diff --git a/src/main/resources/crafter/engine/rendering/controller-context.xml b/src/main/resources/crafter/engine/rendering/controller-context.xml index 459dcc82b..8893ca19d 100644 --- a/src/main/resources/crafter/engine/rendering/controller-context.xml +++ b/src/main/resources/crafter/engine/rendering/controller-context.xml @@ -109,8 +109,10 @@ - - + + + + + + + + + + From 95c8383cb3890b09332ac74aa856e7fcf02fd2b7 Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Sun, 30 Mar 2025 14:26:57 -0400 Subject: [PATCH 2/8] Fixes to cache REST controller so the same API can be used for any type of cache --- .../rest/SiteAppCacheRestController.java | 42 ------- .../rest/SiteCacheRestController.java | 106 ++++++++++++++++++ .../rest/SiteCacheRestControllerBase.java | 86 -------------- .../rest/SiteInternalCacheRestController.java | 78 ------------- .../rest/cache/SiteCacheRestOperations.java | 29 +++++ .../cache/SiteCacheRestOperationsImpl.java | 49 ++++++++ .../cache/SystemSiteCacheRestOperations.java | 43 +++++++ .../exception/InvalidCacheTypeException.java | 17 +++ .../engine/service/context/SiteContext.java | 49 ++++---- .../engine/rendering/controller-context.xml | 28 ++++- 10 files changed, 295 insertions(+), 232 deletions(-) delete mode 100644 src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java create mode 100644 src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java delete mode 100644 src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java delete mode 100644 src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java create mode 100644 src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperations.java create mode 100644 src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperationsImpl.java create mode 100644 src/main/java/org/craftercms/engine/controller/rest/cache/SystemSiteCacheRestOperations.java create mode 100644 src/main/java/org/craftercms/engine/exception/InvalidCacheTypeException.java diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java deleted file mode 100644 index af96936b9..000000000 --- a/src/main/java/org/craftercms/engine/controller/rest/SiteAppCacheRestController.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as published by - * the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.craftercms.engine.controller.rest; - -import org.craftercms.core.controller.rest.CrafterRestController; -import org.craftercms.core.controller.rest.RestControllerBase; -import org.craftercms.core.service.CacheService; -import org.springframework.web.bind.annotation.RequestMapping; - -import java.beans.ConstructorProperties; - -/** - * REST controller for operations related to a site's application cache. - * - * @author avasquez - */ -@CrafterRestController -@RequestMapping(RestControllerBase.REST_BASE_URI + SiteAppCacheRestController.URL_ROOT) -public class SiteAppCacheRestController extends SiteCacheRestControllerBase { - - public static final String URL_ROOT = "/site/app_cache"; - - @ConstructorProperties({"cacheService", "configuredToken"}) - public SiteAppCacheRestController(final CacheService cacheService, final String configuredToken) { - super(cacheService, configuredToken); - } - -} diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java new file mode 100644 index 000000000..575f3d819 --- /dev/null +++ b/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestController.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2007-2025 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.craftercms.engine.controller.rest; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.craftercms.commons.exceptions.InvalidManagementTokenException; +import org.craftercms.core.cache.CacheStatistics; +import org.craftercms.core.controller.rest.CrafterRestController; +import org.craftercms.core.controller.rest.RestControllerBase; +import org.craftercms.engine.controller.rest.cache.SiteCacheRestOperations; +import org.craftercms.engine.exception.InvalidCacheTypeException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +import java.beans.ConstructorProperties; +import java.util.Map; + +/** + * REST controller for site cache operations. The controller uses a map of cache types mapped to + * {@link org.craftercms.engine.controller.rest.cache.SiteCacheRestOperations}, which allows REST operation + * implementations for different types of caches. + * + * @author avasquez + */ +@CrafterRestController +@RequestMapping(RestControllerBase.REST_BASE_URI + SiteCacheRestController.URL_ROOT) +public class SiteCacheRestController extends RestControllerBase { + + private static final Log logger = LogFactory.getLog(SiteCacheRestController.class); + + public static final String URL_ROOT = "/site/cache"; + public static final String URL_CLEAR = "/clear"; + public static final String URL_STATS = "/statistics"; + + protected Map cacheRestOperationsPerCacheType; + protected String defaultCacheType; + protected String configuredToken; + + @ConstructorProperties({"cacheRestOperationsPerCacheType", "defaultCacheType", "configuredToken"}) + public SiteCacheRestController(final Map cacheRestOperationsPerCacheType, + final String defaultCacheType, final String configuredToken) { + this.cacheRestOperationsPerCacheType = cacheRestOperationsPerCacheType; + this.defaultCacheType = defaultCacheType; + this.configuredToken = configuredToken; + } + + @RequestMapping(value = URL_CLEAR, method = RequestMethod.GET) + public Map clear(HttpServletRequest request, @RequestParam String token, + @RequestParam(required = false) String cacheType) throws InvalidManagementTokenException { + validateToken(token); + + return createResponseMessage(getCacheRestOperations(cacheType).clear(request)); + } + + @RequestMapping(value = URL_STATS, method = RequestMethod.GET) + public CacheStatistics getStatistics(@RequestParam String token, + @RequestParam(required = false) String cacheType) throws InvalidManagementTokenException { + validateToken(token); + + return getCacheRestOperations(cacheType).getStatistics(); + } + + @ExceptionHandler(InvalidCacheTypeException.class) + public ResponseEntity> handleInvalidCacheTypeException(InvalidCacheTypeException ex) { + return ResponseEntity.badRequest().body(createResponseMessage(ex.getMessage())); + } + + protected SiteCacheRestOperations getCacheRestOperations(String cacheType) { + if (StringUtils.isEmpty(cacheType)) { + cacheType = defaultCacheType; + } + + var restOperations = cacheRestOperationsPerCacheType.get(cacheType); + if (restOperations == null) { + throw new InvalidCacheTypeException("Unrecognized cache type '" + cacheType + "'."); + } else { + return restOperations; + } + } + + protected final void validateToken(String requestToken) throws InvalidManagementTokenException { + if (!StringUtils.equals(requestToken, configuredToken)) { + throw new InvalidManagementTokenException("Management authorization failed, invalid token."); + } + } + +} diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java b/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java deleted file mode 100644 index fed8338b8..000000000 --- a/src/main/java/org/craftercms/engine/controller/rest/SiteCacheRestControllerBase.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2007-2025 Crafter Software Corporation. All Rights Reserved. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as published by - * the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.craftercms.engine.controller.rest; - -import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.craftercms.commons.exceptions.InvalidManagementTokenException; -import org.craftercms.core.cache.CacheStatistics; -import org.craftercms.core.controller.rest.RestControllerBase; -import org.craftercms.core.service.CacheService; -import org.craftercms.engine.service.context.SiteContext; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; - -import java.beans.ConstructorProperties; -import java.util.Map; - -import static java.lang.String.format; - -/** - * Base class for site-related cache controllers. - * - * @author avasquez - */ -public class SiteCacheRestControllerBase extends RestControllerBase { - - private static final Log logger = LogFactory.getLog(SiteCacheRestControllerBase.class); - - public static final String URL_CLEAR = "/clear"; - public static final String URL_STATS = "/statistics"; - - protected CacheService cacheService; - protected final String configuredToken; - - @ConstructorProperties({"cacheService", "configuredToken"}) - public SiteCacheRestControllerBase(final CacheService cacheService, final String configuredToken) { - this.cacheService = cacheService; - this.configuredToken = configuredToken; - } - - @RequestMapping(value = URL_CLEAR, method = RequestMethod.GET) - public Map clear(HttpServletRequest request, @RequestParam String token) throws InvalidManagementTokenException { - validateToken(token); - - SiteContext siteContext = SiteContext.getCurrent(); - String siteName = siteContext.getSiteName(); - - cacheService.clearScope(siteContext.getContext()); - - String msg = format("Cache clear for site '%s' completed", siteName); - - logger.debug(msg); - - return createResponseMessage(msg); - } - - @RequestMapping(value = URL_STATS, method = RequestMethod.GET) - public CacheStatistics getStatistics(@RequestParam String token) throws InvalidManagementTokenException { - validateToken(token); - - return cacheService.getStatistics(SiteContext.getCurrent().getContext()); - } - - protected final void validateToken(final String requestToken) throws InvalidManagementTokenException { - if (!StringUtils.equals(requestToken, configuredToken)) { - throw new InvalidManagementTokenException("Management authorization failed, invalid token."); - } - } - -} diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java deleted file mode 100644 index d1e5179e5..000000000 --- a/src/main/java/org/craftercms/engine/controller/rest/SiteInternalCacheRestController.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as published by - * the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.craftercms.engine.controller.rest; - -import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.craftercms.commons.exceptions.InvalidManagementTokenException; -import org.craftercms.core.controller.rest.CrafterRestController; -import org.craftercms.core.controller.rest.RestControllerBase; -import org.craftercms.core.service.CacheService; -import org.craftercms.engine.event.SiteContextCreatedEvent; -import org.craftercms.engine.event.SiteEvent; -import org.craftercms.engine.service.context.SiteContext; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; - -import java.beans.ConstructorProperties; -import java.util.Map; - -import static java.lang.String.format; - -/** - * REST controller for operations related to a site's internal cache. - * - * @author avasquez - */ -@CrafterRestController -@RequestMapping(RestControllerBase.REST_BASE_URI + SiteInternalCacheRestController.URL_ROOT) -public class SiteInternalCacheRestController extends SiteCacheRestControllerBase { - - private static final Log logger = LogFactory.getLog(SiteInternalCacheRestController.class); - - public static final String URL_ROOT = "/site/cache"; - - @ConstructorProperties({"cacheService", "configuredToken"}) - public SiteInternalCacheRestController(final CacheService cacheService, final String configuredToken) { - super(cacheService, configuredToken); - } - - @RequestMapping(value = URL_CLEAR, method = RequestMethod.GET) - public Map clear(HttpServletRequest request, @RequestParam String token) throws InvalidManagementTokenException { - validateToken(token); - - SiteContext siteContext = SiteContext.getCurrent(); - String siteName = siteContext.getSiteName(); - String msg; - - // Don't clear cache if the context was just created in this request - if (SiteEvent.getLatestRequestEvent(SiteContextCreatedEvent.class, request) != null) { - msg = format("Site context for '%s' created during the request. Cache clear not necessary", siteName); - } else { - siteContext.startCacheClear(); - - msg = format("Cache clear for site '%s' started", siteName); - } - - logger.debug(msg); - - return createResponseMessage(msg); - } - -} diff --git a/src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperations.java b/src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperations.java new file mode 100644 index 000000000..1aaab4ac2 --- /dev/null +++ b/src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperations.java @@ -0,0 +1,29 @@ +package org.craftercms.engine.controller.rest.cache; + +import jakarta.servlet.http.HttpServletRequest; +import org.craftercms.core.cache.CacheStatistics; + +/** + * Set of REST operations that can be called on a site's cache (a site can have multiple types of cache). + * + * @author avasquez + * @since 4.3.1 + */ +public interface SiteCacheRestOperations { + + /** + * Clear the current site's cache + * + * @param request the current request, used to resolve the site + * @return the response message + */ + String clear(HttpServletRequest request); + + /** + * Get statistics for the current site's cache + * + * @return the {@link CacheStatistics} + */ + CacheStatistics getStatistics(); + +} diff --git a/src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperationsImpl.java b/src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperationsImpl.java new file mode 100644 index 000000000..79d64e0bb --- /dev/null +++ b/src/main/java/org/craftercms/engine/controller/rest/cache/SiteCacheRestOperationsImpl.java @@ -0,0 +1,49 @@ +package org.craftercms.engine.controller.rest.cache; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.craftercms.core.cache.CacheStatistics; +import org.craftercms.core.service.CacheService; +import org.craftercms.engine.service.context.SiteContext; + +import java.beans.ConstructorProperties; + +import static java.lang.String.format; + +/** + * Default implementations of {@link SiteCacheRestOperations}, used on a Crafter {@link CacheService}. + * + * @author avasquez + * @since 4.3.1 + */ +public class SiteCacheRestOperationsImpl implements SiteCacheRestOperations { + + public static final Log logger = LogFactory.getLog(SiteCacheRestOperationsImpl.class); + + protected CacheService cacheService; + + @ConstructorProperties({"cacheService"}) + public SiteCacheRestOperationsImpl(CacheService cacheService) { + this.cacheService = cacheService; + } + + @Override + public String clear(HttpServletRequest request) { + SiteContext siteContext = SiteContext.getCurrent(); + String siteName = siteContext.getSiteName(); + + cacheService.clearScope(siteContext.getContext()); + + String msg = format("Cache clear for site '%s' completed", siteName); + + logger.debug(msg); + + return msg; + } + + @Override + public CacheStatistics getStatistics() { + return cacheService.getStatistics(SiteContext.getCurrent().getContext()); + } +} diff --git a/src/main/java/org/craftercms/engine/controller/rest/cache/SystemSiteCacheRestOperations.java b/src/main/java/org/craftercms/engine/controller/rest/cache/SystemSiteCacheRestOperations.java new file mode 100644 index 000000000..4c03a5ca0 --- /dev/null +++ b/src/main/java/org/craftercms/engine/controller/rest/cache/SystemSiteCacheRestOperations.java @@ -0,0 +1,43 @@ +package org.craftercms.engine.controller.rest.cache; + +import jakarta.servlet.http.HttpServletRequest; +import org.craftercms.core.service.CacheService; +import org.craftercms.engine.event.SiteContextCreatedEvent; +import org.craftercms.engine.event.SiteEvent; +import org.craftercms.engine.service.context.SiteContext; + +import static java.lang.String.format; + +/** + * {@link SiteCacheRestOperations} for a site's internal/system cache. + * + * @author avasquez + * @since 4.3.1 + */ +public class SystemSiteCacheRestOperations extends SiteCacheRestOperationsImpl { + + public SystemSiteCacheRestOperations(CacheService cacheService) { + super(cacheService); + } + + @Override + public String clear(HttpServletRequest request) { + SiteContext siteContext = SiteContext.getCurrent(); + String siteName = siteContext.getSiteName(); + String msg; + + // Don't clear cache if the context was just created in this request + if (SiteEvent.getLatestRequestEvent(SiteContextCreatedEvent.class, request) != null) { + msg = format("Site context for '%s' created during the request. Cache clear not necessary", siteName); + } else { + siteContext.startCacheClear(); + + msg = format("Cache clear for site '%s' started", siteName); + } + + logger.debug(msg); + + return msg; + } + +} diff --git a/src/main/java/org/craftercms/engine/exception/InvalidCacheTypeException.java b/src/main/java/org/craftercms/engine/exception/InvalidCacheTypeException.java new file mode 100644 index 000000000..45db87dcb --- /dev/null +++ b/src/main/java/org/craftercms/engine/exception/InvalidCacheTypeException.java @@ -0,0 +1,17 @@ +package org.craftercms.engine.exception; + +import org.craftercms.core.exception.CrafterException; + +/** + * Thrown when an invalid cache type has been specified as a request parameter + * + * @author avasquez + * @since 4.3.1 + */ +public class InvalidCacheTypeException extends CrafterException { + + public InvalidCacheTypeException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/craftercms/engine/service/context/SiteContext.java b/src/main/java/org/craftercms/engine/service/context/SiteContext.java index d177c054b..16025dec2 100644 --- a/src/main/java/org/craftercms/engine/service/context/SiteContext.java +++ b/src/main/java/org/craftercms/engine/service/context/SiteContext.java @@ -34,7 +34,6 @@ import org.craftercms.engine.util.GroovyScriptUtils; import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor; import org.quartz.Scheduler; -import org.quartz.SchedulerException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -154,7 +153,7 @@ public static void setCurrent(SiteContext siteContext) { release(current); } - logger.debug("Getting access lock for context {}", siteContext); + logger.debug("Getting access lock for {}", siteContext); siteContext.accessLock.lock(); try { @@ -194,7 +193,7 @@ protected static void release(SiteContext siteContext) { siteContext.scriptSandbox.unregister(); } - logger.debug("Releasing access lock for context {}", siteContext); + logger.debug("Releasing access lock for {}", siteContext); siteContext.accessLock.unlock(); } @@ -453,7 +452,7 @@ public void init(boolean waitTillFinished) throws SiteContextInitializationExcep SiteContext.setCurrent(this); try { logger.info("--------------------------------------------------"); - logger.info(""); + logger.info(""); logger.info("--------------------------------------------------"); if (cacheWarmer != null) { @@ -466,7 +465,7 @@ public void init(boolean waitTillFinished) throws SiteContextInitializationExcep state = State.READY; logger.info("--------------------------------------------------"); - logger.info(""); + logger.info(""); logger.info("--------------------------------------------------"); publishEvent(new SiteContextInitializedEvent(this)); @@ -535,54 +534,64 @@ public void startGraphQLSchemaBuild(Runnable callback) throws GraphQLBuildExcept public void destroy() throws CrafterException { boolean locked; try { - logger.debug("Getting shutdown lock for context {}", this); + logger.debug("Getting shutdown lock for {}", this); locked = shutdownLock.tryLock(shutdownTimeout, TimeUnit.MINUTES); try { if (!locked) { - logger.debug("Time out reached, proceeding to destroy context {}", this); + logger.debug("Time out reached, proceeding to destroy {}", this); } else { - logger.debug("All threads released, proceeding to destroy context {}", this); + logger.debug("All threads released, proceeding to destroy {}", this); } state = State.DESTROYED; - publishEvent(new SiteContextDestroyedEvent(this)); - - maintenanceTaskExecutor.shutdownNow(); + try { + publishEvent(new SiteContextDestroyedEvent(this)); + } catch (Exception e) { + logger.error("Error while publishing SiteContextDestroyedEvent for {}", this, e); + } - storeService.destroyContext(context); + try { + maintenanceTaskExecutor.shutdownNow(); + } catch (Exception e) { + logger.error("Error while shutting down maintenance task executor for {}", this, e); + } if (scheduler != null) { try { scheduler.shutdown(); - } catch (SchedulerException e) { - throw new CrafterException("Unable to shutdown scheduler", e); + } catch (Exception e) { + logger.error("Error while shutting scheduler for {}", this, e); } } if (applicationContext != null) { try { applicationContext.close(); } catch (Exception e) { - throw new CrafterException("Unable to close application context", e); + logger.error("Error while closing application context for {}", this, e); } } if (classLoader != null) { try { classLoader.close(); } catch (Exception e) { - throw new CrafterException("Unable to close class loader", e); + logger.error("Error while closing class loader for {}", this, e); } } - } catch (Exception e) { - logger.error("Error destroying context {}", this, e); + + try { + storeService.destroyContext(context); + } catch (Exception e) { + logger.error("Error while destroying core context for {}", this, e); + } } finally { if (locked) { - logger.debug("Releasing shutdown lock for context {}", this); + logger.debug("Releasing shutdown lock for {}", this); shutdownLock.unlock(); } } } catch (InterruptedException e) { - throw new CrafterException("Unable to destroy context", e); + throw new CrafterException("Interrupted while trying to destroy " + this, e); } } diff --git a/src/main/resources/crafter/engine/rendering/controller-context.xml b/src/main/resources/crafter/engine/rendering/controller-context.xml index 8893ca19d..8a3645470 100644 --- a/src/main/resources/crafter/engine/rendering/controller-context.xml +++ b/src/main/resources/crafter/engine/rendering/controller-context.xml @@ -109,9 +109,10 @@ - - + + + @@ -139,18 +140,33 @@ + + + + + + + + + + - + - + + + + + From 44d7fb6e1d40d1db90750c7f3b7a2e8902b3943d Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Thu, 11 Sep 2025 13:23:06 -0400 Subject: [PATCH 3/8] Minor enhancements to S3 serverless related classes --- .../rest/SiteContextRestController.java | 12 +- ...vent.java => SiteContextRemovedEvent.java} | 4 +- .../service/context/SiteContextManager.java | 51 ++--- .../engine/store/s3/S3CachedObject.java | 40 ++++ .../store/s3/S3ContentStoreAdapter.java | 57 +++--- .../craftercms/engine/store/s3/S3Context.java | 4 +- .../craftercms/engine/store/s3/S3Object.java | 20 +- .../craftercms/engine/util/CacheUtils.java | 17 +- .../deployment/DeploymentEventsWatcher.java | 110 ++++------- .../serverless/s3/server-config.properties | 18 +- .../DeploymentEventsWatcherTest.java | 179 ++++++++++++++++++ 11 files changed, 349 insertions(+), 163 deletions(-) rename src/main/java/org/craftercms/engine/event/{SiteContextPurgedEvent.java => SiteContextRemovedEvent.java} (89%) create mode 100644 src/main/java/org/craftercms/engine/store/s3/S3CachedObject.java create mode 100644 src/test/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcherTest.java diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java index d09bc60b2..89fcd05f0 100644 --- a/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java +++ b/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java @@ -77,10 +77,10 @@ public Map destroy(@RequestParam String token) throws InvalidMan String siteName = SiteContext.getCurrent().getSiteName(); - contextManager.startDestroyContext(siteName); + contextManager.startRemoveSiteContext(siteName); - return createResponseMessage(format("Started destroy site context for '%s'. " + - "Will be recreated on next request", siteName)); + return createResponseMessage(format("Started remove of site context for '%s' from the system. If a request " + + "for the site is received in the future, a new site context will be created and registered", siteName)); } @GetMapping(URL_REBUILD_ALL) @@ -102,7 +102,7 @@ public Map rebuild(HttpServletRequest request, @RequestParam Str // Don't rebuild context if the context was just created in this request if (SiteEvent.getLatestRequestEvent(SiteContextCreatedEvent.class, request) != null) { return createResponseMessage(format("Site context for '%s' created during the request. " + - "Context rebuild not necessary", siteName)); + "Context rebuild not necessary", siteName)); } else { contextManager.startContextRebuild(siteName, siteContext.isFallback()); @@ -121,7 +121,7 @@ public Map rebuildSchema(HttpServletRequest request, @RequestPar // Don't rebuild GraphQL schema if the context was just created in this request if (SiteEvent.getLatestRequestEvent(SiteContextCreatedEvent.class, request) != null) { return createResponseMessage(format("Site context for '%s' created during the request. " + - "GraphQL schema rebuild not necessary", siteName)); + "GraphQL schema rebuild not necessary", siteName)); } else { siteContext.startGraphQLSchemaBuild(); @@ -141,4 +141,4 @@ protected final void validateToken(final String requestToken) throws InvalidMana throw new InvalidManagementTokenException("Management authorization failed, invalid token."); } } -} +} \ No newline at end of file diff --git a/src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java b/src/main/java/org/craftercms/engine/event/SiteContextRemovedEvent.java similarity index 89% rename from src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java rename to src/main/java/org/craftercms/engine/event/SiteContextRemovedEvent.java index 36d4e0369..c8c4ef52e 100644 --- a/src/main/java/org/craftercms/engine/event/SiteContextPurgedEvent.java +++ b/src/main/java/org/craftercms/engine/event/SiteContextRemovedEvent.java @@ -22,14 +22,14 @@ * * @author avasquez */ -public class SiteContextPurgedEvent extends SiteEvent { +public class SiteContextRemovedEvent extends SiteEvent { /** * Create a new event. * * @param siteContext the site's context */ - public SiteContextPurgedEvent(SiteContext siteContext) { + public SiteContextRemovedEvent(SiteContext siteContext) { super(siteContext); } diff --git a/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java b/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java index 0f25f159f..b2443b74f 100644 --- a/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java +++ b/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java @@ -24,7 +24,7 @@ import org.craftercms.commons.entitlements.model.EntitlementType; import org.craftercms.commons.entitlements.validator.EntitlementValidator; import org.craftercms.commons.validation.annotations.param.ValidSiteId; -import org.craftercms.engine.event.SiteContextPurgedEvent; +import org.craftercms.engine.event.SiteContextRemovedEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; @@ -370,9 +370,9 @@ public void syncContexts() { contextRegistry.forEach((siteName, siteContext) -> { if (!siteContext.isFallback() && !siteNames.contains(siteName)) { try { - destroyContext(siteName); + removeSiteContext(siteName); } catch (Exception e) { - logger.error("Error destroying site context for site '{}'", siteName, e); + logger.error("Error removing site context for site '{}'", siteName, e); } } }); @@ -478,7 +478,7 @@ public SiteContext getContext(@ValidSiteId String siteName, boolean fallback) { } else if (!siteContext.isValid()) { logger.error("Site context '{}' is not valid anymore", siteContext); - destroyContext(siteName); + removeSiteContext(siteName); siteContext = null; } @@ -566,12 +566,12 @@ public boolean hasValidContext(String siteId) { } /** - * Starts a destroy context in the background + * Starts a remove site context in the background * * @param siteName the site name of the context */ - public void startDestroyContext(String siteName) { - jobThreadPoolExecutor.execute(() -> destroyContext(siteName)); + public void startRemoveSiteContext(String siteName) { + jobThreadPoolExecutor.execute(() -> removeSiteContext(siteName)); } /** @@ -579,8 +579,13 @@ public void startDestroyContext(String siteName) { * * @param siteName the site name of the context to destroy */ - protected void destroyContext(String siteName) { + protected void removeSiteContext(String siteName) { SiteContext siteContext; + + logger.info("=================================================="); + logger.info("", siteName); + logger.info("=================================================="); + Lock siteLock = siteLockFactory.getLock(siteName); siteLock.lock(); try { @@ -604,39 +609,15 @@ protected void destroyContext(String siteName) { } if (siteContext != null) { - logger.info("=================================================="); - logger.info("", siteName); - logger.info("=================================================="); - try { destroyContext(siteContext); } finally { - applicationContext.publishEvent(new SiteContextPurgedEvent(siteContext)); + applicationContext.publishEvent(new SiteContextRemovedEvent(siteContext)); } - - logger.info("=================================================="); - logger.info("", siteName); - logger.info("=================================================="); } - } - protected void destroyContexts(Collection siteNames) { logger.info("=================================================="); - logger.info(""); - logger.info("=================================================="); - - if (CollectionUtils.isNotEmpty(siteNames)) { - for (String siteName : siteNames) { - try { - destroyContext(siteName); - } catch (Exception e) { - logger.error("Error destroying site context for site '{}'", siteName, e); - } - } - } - - logger.info("=================================================="); - logger.info(""); + logger.info("", siteName); logger.info("=================================================="); } @@ -705,4 +686,4 @@ public void startRebuildAll() { startContextRebuild(siteContext.getSiteName(), siteContext.isFallback()) ); } -} +} \ No newline at end of file diff --git a/src/main/java/org/craftercms/engine/store/s3/S3CachedObject.java b/src/main/java/org/craftercms/engine/store/s3/S3CachedObject.java new file mode 100644 index 000000000..35a1083ca --- /dev/null +++ b/src/main/java/org/craftercms/engine/store/s3/S3CachedObject.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2007-2025 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.craftercms.engine.store.s3; + +import java.io.ByteArrayInputStream; + +/** + * Represents an S3 object that can be cached internal or in a distributed cache like Redis. + * + * @author avasquez + * @since 4.5.0 + */ +public class S3CachedObject extends S3Object { + + protected byte[] content; + + public S3CachedObject(String bucketName, String key, long lastModified, long contentLength, byte[] content) { + super(bucketName, key, lastModified, contentLength, () -> new ByteArrayInputStream(content)); + this.content = content; + } + + public byte[] getContent() { + return content; + } + +} diff --git a/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java b/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java index 1b3416583..f7a1d3201 100644 --- a/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java +++ b/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java @@ -18,7 +18,6 @@ import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpStatus; import org.craftercms.commons.lang.RegexUtils; import org.craftercms.core.exception.AuthenticationException; @@ -46,7 +45,6 @@ import software.amazon.awssdk.services.s3.paginators.ListObjectsV2Iterable; import java.beans.ConstructorProperties; -import java.io.ByteArrayInputStream; import java.io.InputStream; import java.net.URI; import java.util.List; @@ -58,6 +56,7 @@ /** * Implementation of {@link org.craftercms.core.store.ContentStoreAdapter} to read files from AWS S3. + * * @author joseross */ public class S3ContentStoreAdapter extends AbstractCachedFileBasedContentStoreAdapter implements InitializingBean, DisposableBean { @@ -72,7 +71,7 @@ public class S3ContentStoreAdapter extends AbstractCachedFileBasedContentStoreAd protected final String[] cacheAllowedPaths; @ConstructorProperties({"pathValidator", "descriptorFileExtension", "metadataFileExtension", "cacheTemplate", - "clientBuilder", "contentMaxLength", "cacheAllowedPaths"}) + "clientBuilder", "contentMaxLength", "cacheAllowedPaths"}) public S3ContentStoreAdapter(Validator pathValidator, String descriptorFileExtension, String metadataFileExtension, CacheTemplate cacheTemplate, final S3ClientBuilder clientBuilder, final int contentMaxLength, final String[] cacheAllowedPaths) { @@ -93,12 +92,13 @@ public void destroy() { /** * Check if the result of listing S3 object response is empty + * * @param result instance of {@link ListObjectsV2Response} * @return true if the result is empty, false otherwise */ protected boolean isResultEmpty(ListObjectsV2Response result) { return (!result.hasCommonPrefixes() || result.commonPrefixes().isEmpty()) - && (!result.hasContents() || result.contents().isEmpty()); + && (!result.hasContents() || result.contents().isEmpty()); } /** @@ -108,7 +108,7 @@ protected boolean isResultEmpty(ListObjectsV2Response result) { public Context createContext(final String id, final String rootFolderPath, final boolean mergingOn, final boolean cacheOn, final int maxAllowedItemsInCache, final boolean ignoreHiddenFiles, Map configurationVariables) - throws RootFolderNotFoundException, StoreException, AuthenticationException { + throws RootFolderNotFoundException, StoreException, AuthenticationException { S3Uri uri = client.utilities().parseUri(URI.create(removeEnd(rootFolderPath, DELIMITER))); ListObjectsV2Request request = ListObjectsV2Request.builder() @@ -122,7 +122,7 @@ public Context createContext(final String id, final String rootFolderPath, final } return new S3Context(id, this, rootFolderPath, mergingOn, cacheOn, maxAllowedItemsInCache, - ignoreHiddenFiles, uri, configurationVariables); + ignoreHiddenFiles, uri, configurationVariables); } @Override @@ -171,7 +171,7 @@ protected File doFindFile(Context context, String path) throws InvalidContextExc } } else { // If it is a file, get metadata and content for the file - return getObject(s3Context, client, bucketName, key); + return getObject(s3Context, client, bucketName, key); } return null; } @@ -181,7 +181,7 @@ protected File doFindFile(Context context, String path) throws InvalidContextExc */ @Override protected List doGetChildren(Context context, File dir) - throws InvalidContextException, StoreException { + throws InvalidContextException, StoreException { if (!(dir instanceof S3Prefix s3Prefix)) { throw new StoreException(format("'%s' is not an S3 prefix", dir)); @@ -205,7 +205,7 @@ protected List doGetChildren(Context context, File dir) .forEach(p -> children.add(new S3Prefix(bucketName, p.prefix()))); page.contents().stream() - .filter(s-> !context.ignoreHiddenFiles() || !isHidden(s.key())) + .filter(s -> !context.ignoreHiddenFiles() || !isHidden(s.key())) .forEach(s -> children.add(new S3File(bucketName, s.key()))); } @@ -227,6 +227,7 @@ public boolean validate(final Context context) throws StoreException, Authentica .bucket(bucketName) .prefix(rootPrefix) .delimiter(DELIMITER) + .maxKeys(1) .build(); ListObjectsV2Response result = client.listObjectsV2(request); @@ -245,16 +246,16 @@ private boolean isHidden(final String path) { return FilenameUtils.getName(path).startsWith("."); } - /** * Get S3 object metadata and content + * * @param context the S3 context - * @param client instance of {@link S3Client} - * @param bucket bucket name - * @param key key name + * @param client instance of {@link S3Client} + * @param bucket bucket name + * @param key key name * @return the S3 object, metadata and content included */ - private S3Object getObject(S3Context context, S3Client client, String bucket, String key) { + protected S3Object getObject(S3Context context, S3Client client, String bucket, String key) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) .key(key) @@ -266,17 +267,16 @@ private S3Object getObject(S3Context context, S3Client client, String bucket, St GetObjectResponse objectResp = objectIS.response(); long lastModified = objectResp.lastModified().toEpochMilli(); long contentLength = objectResp.contentLength(); - Supplier contentSupplier; if (shouldCache(context, key, contentLength)) { byte[] content = IOUtils.toByteArray(objectIS, contentLength); - contentSupplier = () -> new ByteArrayInputStream(content); + return new S3CachedObject(bucket, key, lastModified, contentLength, content); } else { objectIS.abort(); - contentSupplier = () -> client.getObject(getObjectRequest); - } - return new S3Object(bucket, key, lastModified, contentLength, contentSupplier); + Supplier contentSupplier = getContentSupplierForS3Object(bucket, key); + return new S3Object(bucket, key, lastModified, contentLength, contentSupplier); + } } catch (NoSuchKeyException e) { logger.debug("No S3 object found at 's3://{}/{}'", bucket, key); @@ -290,19 +290,30 @@ private S3Object getObject(S3Context context, S3Client client, String bucket, St * Indicates if the content should be cached in memory. * Content is cached if path matches the 'cacheAllowedPaths` and * the content length is not greater than contentMaxLength - * @param context the S3 context - * @param key the S3 object key + * + * @param context the S3 context + * @param key the S3 object key * @param contentLength the S3 object content length * @return true if the content should be cached in memory, false otherwise */ - private boolean shouldCache(S3Context context, String key, long contentLength) { + protected boolean shouldCache(S3Context context, String key, long contentLength) { String folderPrefix = context.getKey(); String path = removeStart(stripStart(key, DELIMITER), stripStart(folderPrefix, DELIMITER)); if (!RegexUtils.matchesAny(path, cacheAllowedPaths)) { return false; } + return contentLength <= contentMaxLength; } -} + protected Supplier getContentSupplierForS3Object(String bucket, String key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + return () -> client.getObject(getObjectRequest); + } + +} \ No newline at end of file diff --git a/src/main/java/org/craftercms/engine/store/s3/S3Context.java b/src/main/java/org/craftercms/engine/store/s3/S3Context.java index aeefa154b..ad8237bfe 100644 --- a/src/main/java/org/craftercms/engine/store/s3/S3Context.java +++ b/src/main/java/org/craftercms/engine/store/s3/S3Context.java @@ -47,7 +47,7 @@ public S3Context(final String id, final ContentStoreAdapter storeAdapter, final * Returns the name of the bucket. */ public String getBucket() { - return rootFolderUri.bucket().orElseThrow(() -> new S3BucketNotConfiguredException()); + return rootFolderUri.bucket().orElseThrow(S3BucketNotConfiguredException::new); } /** @@ -57,4 +57,4 @@ public String getKey() { return rootFolderUri.key().orElse(""); } -} +} \ No newline at end of file diff --git a/src/main/java/org/craftercms/engine/store/s3/S3Object.java b/src/main/java/org/craftercms/engine/store/s3/S3Object.java index 62e5e530e..fd32b139f 100644 --- a/src/main/java/org/craftercms/engine/store/s3/S3Object.java +++ b/src/main/java/org/craftercms/engine/store/s3/S3Object.java @@ -48,10 +48,10 @@ public class S3Object extends S3File implements Content { /** * Main constructor. * - * @param bucketName the S3 bucket - * @param key the S3 key - * @param lastModified the last modified timestamp - * @param contentLength the content size + * @param bucketName the S3 bucket + * @param key the S3 key + * @param lastModified the last modified timestamp + * @param contentLength the content size * @param contentSupplier an InputStream supplier for the content */ public S3Object(String bucketName, String key, long lastModified, long contentLength, @@ -81,10 +81,10 @@ public InputStream getInputStream() { @Override public String toString() { return "S3Object{" + - "bucketName='" + bucketName + '\'' + - ", key='" + key + '\'' + - ", lastModified=" + lastModified + - ", contentLength=" + FileUtils.byteCountToDisplaySize(contentLength) + - '}'; + "bucketName='" + bucketName + '\'' + + ", key='" + key + '\'' + + ", lastModified=" + lastModified + + ", contentLength=" + FileUtils.byteCountToDisplaySize(contentLength) + + '}'; } -} +} \ No newline at end of file diff --git a/src/main/java/org/craftercms/engine/util/CacheUtils.java b/src/main/java/org/craftercms/engine/util/CacheUtils.java index bfb847623..1649fb872 100644 --- a/src/main/java/org/craftercms/engine/util/CacheUtils.java +++ b/src/main/java/org/craftercms/engine/util/CacheUtils.java @@ -1,5 +1,6 @@ package org.craftercms.engine.util; +import org.apache.commons.lang3.ArrayUtils; import org.craftercms.core.service.ContentStoreService; /* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. @@ -36,16 +37,18 @@ public class CacheUtils { public static Map parsePreloadFoldersList(String[] preloadFolders) { Map preloadFoldersMappings = new HashMap<>(); - for (String folder : preloadFolders) { - String[] folderAndDepth = folder.split(":"); - if (folderAndDepth.length > 1) { - preloadFoldersMappings.put(folderAndDepth[0], Integer.parseInt(folderAndDepth[1])); - } else if (folderAndDepth.length == 1) { - preloadFoldersMappings.put(folderAndDepth[0], ContentStoreService.UNLIMITED_TREE_DEPTH); + if (ArrayUtils.isNotEmpty(preloadFolders)) { + for (String folder : preloadFolders) { + String[] folderAndDepth = folder.split(":"); + if (folderAndDepth.length > 1) { + preloadFoldersMappings.put(folderAndDepth[0], Integer.parseInt(folderAndDepth[1])); + } else if (folderAndDepth.length == 1) { + preloadFoldersMappings.put(folderAndDepth[0], ContentStoreService.UNLIMITED_TREE_DEPTH); + } } } return preloadFoldersMappings; } -} +} \ No newline at end of file diff --git a/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java b/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java index a0db925b7..35d758443 100644 --- a/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java +++ b/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java @@ -15,6 +15,7 @@ */ package org.craftercms.engine.util.deployment; +import org.apache.commons.lang3.StringUtils; import org.craftercms.core.service.CachingOptions; import org.craftercms.core.service.Content; import org.craftercms.core.service.ContentStoreService; @@ -49,25 +50,19 @@ public class DeploymentEventsWatcher implements ApplicationListener latestDeploymentEvents; - private Map latestSiteContextEvents; + protected String deploymentEventsFileUrl; + protected SiteContextManager siteContextManager; + protected volatile boolean startupCompleted; + protected Map latestDeploymentEventsPerSite; public DeploymentEventsWatcher(SiteContextManager siteContextManager) { this.deploymentEventsFileUrl = DEFAULT_DEPLOYMENT_EVENTS_FILE_URL; this.startupCompleted = false; - this.latestDeploymentEvents = new ConcurrentHashMap<>(); - this.latestSiteContextEvents = new ConcurrentHashMap<>(); - + this.latestDeploymentEventsPerSite = new ConcurrentHashMap<>(); this.siteContextManager = siteContextManager; } @@ -88,9 +83,8 @@ public void checkForEvents() { } public void checkForSiteEvents(SiteContext siteContext) { - boolean rebuildContextTriggered = false; String siteName = siteContext.getSiteName(); - Properties pastDeploymentEvents = latestDeploymentEvents.get(siteName); + Properties latestDeploymentEvents = latestDeploymentEventsPerSite.get(siteName); Properties currentDeploymentEvents; try { @@ -102,53 +96,48 @@ public void checkForSiteEvents(SiteContext siteContext) { logger.debug("Checking deployment events for site {}...", siteName); - if (Objects.equals(currentDeploymentEvents, pastDeploymentEvents)) { + if (latestDeploymentEvents == null) { + logger.debug("No previous deployment events detected for site {}. Saving latest...", siteName); + + latestDeploymentEventsPerSite.put(siteName, currentDeploymentEvents); + } else if (Objects.equals(currentDeploymentEvents, latestDeploymentEvents)) { logger.debug("No new deployment events for site {}", siteName); } else { logger.debug("New deployment events received for site {}", siteName); - latestDeploymentEvents.put(siteName, currentDeploymentEvents); - - long lastContextBuildEvent = getLatestEventTimestamp(siteName, SiteContextCreatedEvent.class); + long latestRebuildContextEvent = getEventProperty(latestDeploymentEvents, REBUILD_CONTEXT_EVENT_KEY); + long currentRebuildContextEvent = getEventProperty(currentDeploymentEvents, REBUILD_CONTEXT_EVENT_KEY); - if (currentDeploymentEvents.containsKey(REBUILD_CONTEXT_EVENT_KEY)) { - long rebuildContextEvent = getEventProperty(currentDeploymentEvents, REBUILD_CONTEXT_EVENT_KEY); - - if (lastContextBuildEvent < rebuildContextEvent) { - logger.info("Rebuild context deployment event received. Rebuilding context for site {}...", siteName); - - siteContextManager.startContextRebuild( - siteContext.getSiteName(), - siteContext.isFallback(), - newContext -> logger.info("Context rebuild for site {} completed", siteName)); - - rebuildContextTriggered = true; - } - } + if (latestRebuildContextEvent < currentRebuildContextEvent) { + logger.info("Rebuild context deployment event received. Rebuilding context for site {}...", siteName); - if (!rebuildContextTriggered && currentDeploymentEvents.containsKey(CLEAR_CACHE_EVENT_KEY)) { - long clearCacheEvent = getEventProperty(currentDeploymentEvents, CLEAR_CACHE_EVENT_KEY); - long lastCacheClearEvent = getLatestEventTimestamp(siteName, CacheClearedEvent.class); + siteContextManager.startContextRebuild( + siteContext.getSiteName(), + siteContext.isFallback(), + newContext -> logger.info("Context rebuild for site {} completed", siteName)); + } else { + long latestCacheClearEvent = getEventProperty(latestDeploymentEvents, CLEAR_CACHE_EVENT_KEY); + long currentClearCacheEvent = getEventProperty(currentDeploymentEvents, CLEAR_CACHE_EVENT_KEY); - if (lastContextBuildEvent < clearCacheEvent && lastCacheClearEvent < clearCacheEvent) { + if (latestCacheClearEvent < currentClearCacheEvent) { logger.info("Clear cache deployment event received. Clearing cache for site {}...", siteName); siteContext.startCacheClear( () -> logger.info("Clear cache for site {} completed", siteName)); } - } - if (!rebuildContextTriggered && currentDeploymentEvents.containsKey(REBUILD_GRAPHQL_EVENT_KEY)) { - long rebuildGraphQLEvent = getEventProperty(currentDeploymentEvents, REBUILD_GRAPHQL_EVENT_KEY); - long lastRebuildGraphQLEvent = getLatestEventTimestamp(siteName, GraphQLBuiltEvent.class); + long latestRebuildGraphQLEvent = getEventProperty(latestDeploymentEvents, REBUILD_GRAPHQL_EVENT_KEY); + long currentRebuildGraphQLEvent = getEventProperty(currentDeploymentEvents, REBUILD_GRAPHQL_EVENT_KEY); - if (lastContextBuildEvent < rebuildGraphQLEvent && lastRebuildGraphQLEvent < rebuildGraphQLEvent) { + if (latestRebuildGraphQLEvent < currentRebuildGraphQLEvent) { logger.info("Rebuild GraphQL deployment event received. Rebuilding schema for site {}...", siteName); siteContext.startGraphQLSchemaBuild( () -> logger.info("GraphQL schema rebuild for site {} completed", siteName)); } } + + latestDeploymentEventsPerSite.put(siteName, currentDeploymentEvents); } } @@ -156,26 +145,13 @@ public void checkForSiteEvents(SiteContext siteContext) { public void onApplicationEvent(ApplicationEvent event) { if (event instanceof SiteContextsBootstrappedEvent) { startupCompleted = true; - } else if (event instanceof SiteContextPurgedEvent) { + } else if (event instanceof SiteContextRemovedEvent) { String siteName = ((SiteEvent) event).getSiteContext().getSiteName(); logger.debug("Clearing all deployment events info for removed site '{}'", siteName); // The site was completely removed, so remove all related event info - latestDeploymentEvents.remove(siteName); - latestSiteContextEvents.remove(String.format(LATEST_EVENT_KEY_FORMAT, siteName, SiteContextCreatedEvent.class)); - latestSiteContextEvents.remove(String.format(LATEST_EVENT_KEY_FORMAT, siteName, CacheClearedEvent.class)); - latestSiteContextEvents.remove(String.format(LATEST_EVENT_KEY_FORMAT, siteName, GraphQLBuiltEvent.class)); - } else if (event instanceof SiteEvent) { - SiteEvent siteEvent = (SiteEvent) event; - String siteName = siteEvent.getSiteContext().getSiteName(); - Class eventClass = siteEvent.getClass(); - - if (eventClass.equals(SiteContextCreatedEvent.class) || - eventClass.equals(CacheClearedEvent.class) || - eventClass.equals(GraphQLBuiltEvent.class)) { - latestSiteContextEvents.put(String.format(LATEST_EVENT_KEY_FORMAT, siteName, eventClass), siteEvent); - } + latestDeploymentEventsPerSite.remove(siteName); } } @@ -184,15 +160,6 @@ public boolean supportsAsyncExecution() { return false; } - private long getLatestEventTimestamp(String siteName, Class eventClass) { - SiteEvent event = latestSiteContextEvents.get(String.format(LATEST_EVENT_KEY_FORMAT, siteName, eventClass)); - if (event != null) { - return event.getTimestamp(); - } else { - return -1; - } - } - private Properties loadDeploymentEvents(SiteContext siteContext) throws IOException { ContentStoreService contentStoreService = siteContext.getStoreService(); Context context = siteContext.getContext(); @@ -208,7 +175,12 @@ private Properties loadDeploymentEvents(SiteContext siteContext) throws IOExcept } private long getEventProperty(Properties deploymentEvents, String name) { - return Instant.parse(deploymentEvents.getProperty(name)).toEpochMilli(); + String eventTimestamp = deploymentEvents.getProperty(name); + if (StringUtils.isNotEmpty(eventTimestamp)) { + return Instant.parse(eventTimestamp).toEpochMilli(); + } else { + return 0; + } } -} +} \ No newline at end of file diff --git a/src/main/resources/crafter/engine/mode/serverless/s3/server-config.properties b/src/main/resources/crafter/engine/mode/serverless/s3/server-config.properties index 023f46c94..efcff9d7b 100644 --- a/src/main/resources/crafter/engine/mode/serverless/s3/server-config.properties +++ b/src/main/resources/crafter/engine/mode/serverless/s3/server-config.properties @@ -29,12 +29,12 @@ crafter.engine.site.context.waitForInit=true crafter.engine.store.s3.cache.contentMaxLength=10485760 # White list of paths to be cached in memory when using S3 store. crafter.engine.store.s3.cache.allowedPaths=\ - /config/.*,\ - /site/.*,\ - /scripts/.*,\ - /templates/.*,\ - /static-assets/css/.*,\ - /static-assets/js/.*,\ - /static-assets/fonts/.*,\ - /static-assets/app/.*,\ - /static-assets/seo/.* + /config/.*,\ + /site/.*,\ + /scripts/.*,\ + /templates/.*,\ + /static-assets/css/.*,\ + /static-assets/js/.*,\ + /static-assets/fonts/.*,\ + /static-assets/app/.*,\ + /static-assets/seo/.* diff --git a/src/test/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcherTest.java b/src/test/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcherTest.java new file mode 100644 index 000000000..cd6c72752 --- /dev/null +++ b/src/test/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcherTest.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2007-2025 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.craftercms.engine.util.deployment; + +import org.craftercms.core.service.Content; +import org.craftercms.core.service.ContentStoreService; +import org.craftercms.core.service.Context; +import org.craftercms.engine.service.context.SiteContext; +import org.craftercms.engine.service.context.SiteContextManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.Instant; +import java.util.Collections; +import java.util.Properties; +import java.util.function.Consumer; + +import static org.craftercms.core.service.CachingOptions.*; +import static org.craftercms.engine.util.deployment.DeploymentEventsWatcher.*; +import static org.mockito.Mockito.*; + +/** + * Unit test for {@link DeploymentEventsWatcher} + * + * @author avasquez + * @since 4.4.5 + */ +public class DeploymentEventsWatcherTest { + + private static final String SITENAME = "mysite"; + + @Mock + private Context context; + @Mock + private SiteContext siteContext; + @Mock + private ContentStoreService contentStoreService; + @Mock + private SiteContextManager siteContextManager; + + private AutoCloseable closeable; + private DeploymentEventsWatcher watcher; + + @Before + public void setUp() throws Exception { + closeable = MockitoAnnotations.openMocks(this); + + when(siteContext.getSiteName()).thenReturn(SITENAME); + when(siteContext.getContext()).thenReturn(context); + when(siteContext.getStoreService()).thenReturn(contentStoreService); + when(siteContextManager.listContexts()).thenReturn(Collections.singletonList(siteContext)); + + watcher = spy(new DeploymentEventsWatcher(siteContextManager)); + watcher.startupCompleted = true; + watcher.latestDeploymentEventsPerSite = spy(watcher.latestDeploymentEventsPerSite); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test + public void checkForNoPreviousEventsTest() throws IOException { + Instant now = Instant.now(); + Properties deploymentEvents = createDeploymentEvents(now, now, now); + Content deploymentEventsContent = getDeploymentEventsAsContent(deploymentEvents); + + when(contentStoreService.findContent(context, CACHE_OFF_CACHING_OPTIONS, DEFAULT_DEPLOYMENT_EVENTS_FILE_URL)).thenReturn(deploymentEventsContent); + + watcher.checkForEvents(); + + verify(siteContextManager, never()).startContextRebuild(anyString(), anyBoolean(), any(Consumer.class)); + verify(siteContext, never()).startCacheClear(any(Runnable.class)); + verify(siteContext, never()).startGraphQLSchemaBuild(any(Runnable.class)); + verify(watcher.latestDeploymentEventsPerSite, times(1)).put(SITENAME, deploymentEvents); + } + + @Test + public void checkForNoNewEventsTest() throws IOException { + Instant now = Instant.now(); + Properties lastDeploymentEvents = createDeploymentEvents(now, now, now); + + watcher.latestDeploymentEventsPerSite.put(SITENAME, lastDeploymentEvents); + + Properties currDeploymentEvents = createDeploymentEvents(now, now, now); + Content currDeploymentEventsContent = getDeploymentEventsAsContent(currDeploymentEvents); + + when(contentStoreService.findContent(context, CACHE_OFF_CACHING_OPTIONS, DEFAULT_DEPLOYMENT_EVENTS_FILE_URL)).thenReturn(currDeploymentEventsContent); + + watcher.checkForEvents(); + + verify(siteContextManager, never()).startContextRebuild(anyString(), anyBoolean(), any(Consumer.class)); + verify(siteContext, never()).startCacheClear(any(Runnable.class)); + verify(siteContext, never()).startGraphQLSchemaBuild(any(Runnable.class)); + } + + @Test + public void checkForAllNewEventsTest() throws IOException { + Instant now = Instant.now(); + Properties lastDeploymentEvents = createDeploymentEvents(now, now, now); + + watcher.latestDeploymentEventsPerSite.put(SITENAME, lastDeploymentEvents); + + Instant tenSecsLater = now.plusSeconds(10); + Properties currDeploymentEvents = createDeploymentEvents(tenSecsLater, tenSecsLater, tenSecsLater); + Content currDeploymentEventsContent = getDeploymentEventsAsContent(currDeploymentEvents); + + when(contentStoreService.findContent(context, CACHE_OFF_CACHING_OPTIONS, DEFAULT_DEPLOYMENT_EVENTS_FILE_URL)).thenReturn(currDeploymentEventsContent); + + watcher.checkForEvents(); + + verify(siteContextManager, times(1)).startContextRebuild(anyString(), anyBoolean(), any(Consumer.class)); + verify(siteContext, never()).startCacheClear(any(Runnable.class)); + verify(siteContext, never()).startGraphQLSchemaBuild(any(Runnable.class)); + verify(watcher.latestDeploymentEventsPerSite, times(1)).put(SITENAME, currDeploymentEvents); + } + + @Test + public void checkForNewCacheClearAndRebuildGraphQLEventsTest() throws IOException { + Instant now = Instant.now(); + Properties lastDeploymentEvents = createDeploymentEvents(now, now, now); + + watcher.latestDeploymentEventsPerSite.put(SITENAME, lastDeploymentEvents); + + Instant tenSecsLater = now.plusSeconds(10); + Properties currDeploymentEvents = createDeploymentEvents(now, tenSecsLater, tenSecsLater); + Content currDeploymentEventsContent = getDeploymentEventsAsContent(currDeploymentEvents); + + when(contentStoreService.findContent(context, CACHE_OFF_CACHING_OPTIONS, DEFAULT_DEPLOYMENT_EVENTS_FILE_URL)).thenReturn(currDeploymentEventsContent); + + watcher.checkForEvents(); + + verify(siteContextManager, never()).startContextRebuild(anyString(), anyBoolean(), any(Consumer.class)); + verify(siteContext, times(1)).startCacheClear(any(Runnable.class)); + verify(siteContext, times(1)).startGraphQLSchemaBuild(any(Runnable.class)); + verify(watcher.latestDeploymentEventsPerSite, times(1)).put(SITENAME, currDeploymentEvents); + } + + private Properties createDeploymentEvents(Instant rebuildContextEvent, Instant clearCacheEvent, Instant rebuildGraphQLEvent) { + Properties deploymentEvents = new Properties(); + deploymentEvents.setProperty(REBUILD_CONTEXT_EVENT_KEY, rebuildContextEvent.toString()); + deploymentEvents.setProperty(CLEAR_CACHE_EVENT_KEY, clearCacheEvent.toString()); + deploymentEvents.setProperty(REBUILD_GRAPHQL_EVENT_KEY, rebuildGraphQLEvent.toString()); + + return deploymentEvents; + } + + private Content getDeploymentEventsAsContent(Properties deploymentEvents) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + deploymentEvents.store(out, null); + + Content content = mock(Content.class); + when(content.getInputStream()).thenReturn(new ByteArrayInputStream(out.toByteArray())); + + return content; + } + +} \ No newline at end of file From e081bdcd8d61df74fb99980b612ddbb5ce9f8fd0 Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Thu, 11 Sep 2025 14:44:46 -0400 Subject: [PATCH 4/8] CR recommendations --- .../controller/rest/SiteContextRestController.java | 4 ++-- .../engine/store/s3/S3ContentStoreAdapter.java | 13 ++++++++++--- .../util/deployment/DeploymentEventsWatcher.java | 6 +++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java b/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java index 89fcd05f0..4d3b04732 100644 --- a/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java +++ b/src/main/java/org/craftercms/engine/controller/rest/SiteContextRestController.java @@ -79,8 +79,8 @@ public Map destroy(@RequestParam String token) throws InvalidMan contextManager.startRemoveSiteContext(siteName); - return createResponseMessage(format("Started remove of site context for '%s' from the system. If a request " + - "for the site is received in the future, a new site context will be created and registered", siteName)); + return createResponseMessage(format("Started removal of site context for '%s' from the system. If a request " + + "for the site is received in the future, a new site context will be created and registered.", siteName)); } @GetMapping(URL_REBUILD_ALL) diff --git a/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java b/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java index f7a1d3201..cc41ec168 100644 --- a/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java +++ b/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java @@ -272,10 +272,16 @@ protected S3Object getObject(S3Context context, S3Client client, String bucket, byte[] content = IOUtils.toByteArray(objectIS, contentLength); return new S3CachedObject(bucket, key, lastModified, contentLength, content); } else { + String eTag = objectResp.eTag(); + objectIS.abort(); - Supplier contentSupplier = getContentSupplierForS3Object(bucket, key); - return new S3Object(bucket, key, lastModified, contentLength, contentSupplier); + return new S3Object( + bucket, + key, + lastModified, + contentLength, + getContentSupplierForS3Object(bucket, key, eTag)); } } catch (NoSuchKeyException e) { logger.debug("No S3 object found at 's3://{}/{}'", bucket, key); @@ -307,10 +313,11 @@ protected boolean shouldCache(S3Context context, String key, long contentLength) return contentLength <= contentMaxLength; } - protected Supplier getContentSupplierForS3Object(String bucket, String key) { + protected Supplier getContentSupplierForS3Object(String bucket, String key, String eTag) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) .key(key) + .ifMatch(eTag) .build(); return () -> client.getObject(getObjectRequest); diff --git a/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java b/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java index 35d758443..eff0cf655 100644 --- a/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java +++ b/src/main/java/org/craftercms/engine/util/deployment/DeploymentEventsWatcher.java @@ -29,6 +29,7 @@ import org.springframework.context.ApplicationListener; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -168,7 +169,10 @@ private Properties loadDeploymentEvents(SiteContext siteContext) throws IOExcept Properties events = new Properties(); if (content != null) { - events.load(new InputStreamReader(content.getInputStream(), StandardCharsets.UTF_8)); + try (InputStream is = content.getInputStream(); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + events.load(reader); + } } return events; From f1156d0f7c6da5d58896869f33e30ffcb208b535 Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Thu, 11 Sep 2025 14:56:06 -0400 Subject: [PATCH 5/8] CR recommendations --- .../craftercms/engine/service/context/SiteContextManager.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java b/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java index b2443b74f..f873531dc 100644 --- a/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java +++ b/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java @@ -597,6 +597,9 @@ protected void removeSiteContext(String siteName) { logger.warn("Error while removing directory watcher register for site '{}'", siteName, e); } } + // Clear per-site watcher state + directoryWatcherLastProcessedHash.remove(siteName); + directoryWatcherCounter.remove(siteName); if (directoryWatcherExecutor.get(siteName) != null) { ScheduledExecutorService executor = directoryWatcherExecutor.remove(siteName); From 667f03a5a47f084e2b246ad5ce673c09b89a119c Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Thu, 11 Sep 2025 15:09:11 -0400 Subject: [PATCH 6/8] Revert "CR recommendations" This reverts commit f1156d0f7c6da5d58896869f33e30ffcb208b535. --- .../craftercms/engine/service/context/SiteContextManager.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java b/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java index f873531dc..b2443b74f 100644 --- a/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java +++ b/src/main/java/org/craftercms/engine/service/context/SiteContextManager.java @@ -597,9 +597,6 @@ protected void removeSiteContext(String siteName) { logger.warn("Error while removing directory watcher register for site '{}'", siteName, e); } } - // Clear per-site watcher state - directoryWatcherLastProcessedHash.remove(siteName); - directoryWatcherCounter.remove(siteName); if (directoryWatcherExecutor.get(siteName) != null) { ScheduledExecutorService executor = directoryWatcherExecutor.remove(siteName); From 70e95a1a7ef42d5cddb4e5ee3a209bb8f3f010b5 Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Thu, 11 Sep 2025 18:05:33 -0400 Subject: [PATCH 7/8] Fixing S3ContentStoreAdapter --- .../engine/store/s3/S3ContentStoreAdapter.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java b/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java index cc41ec168..f7446a5fc 100644 --- a/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java +++ b/src/main/java/org/craftercms/engine/store/s3/S3ContentStoreAdapter.java @@ -47,6 +47,7 @@ import java.beans.ConstructorProperties; import java.io.InputStream; import java.net.URI; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -272,8 +273,6 @@ protected S3Object getObject(S3Context context, S3Client client, String bucket, byte[] content = IOUtils.toByteArray(objectIS, contentLength); return new S3CachedObject(bucket, key, lastModified, contentLength, content); } else { - String eTag = objectResp.eTag(); - objectIS.abort(); return new S3Object( @@ -281,7 +280,7 @@ protected S3Object getObject(S3Context context, S3Client client, String bucket, key, lastModified, contentLength, - getContentSupplierForS3Object(bucket, key, eTag)); + getContentSupplierForS3Object(bucket, key, lastModified)); } } catch (NoSuchKeyException e) { logger.debug("No S3 object found at 's3://{}/{}'", bucket, key); @@ -313,11 +312,11 @@ protected boolean shouldCache(S3Context context, String key, long contentLength) return contentLength <= contentMaxLength; } - protected Supplier getContentSupplierForS3Object(String bucket, String key, String eTag) { + protected Supplier getContentSupplierForS3Object(String bucket, String key, long lastModified) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) .key(key) - .ifMatch(eTag) + .ifUnmodifiedSince(Instant.ofEpochMilli(lastModified)) .build(); return () -> client.getObject(getObjectRequest); From e12a5ce4504c7a9165c085d72b5347b16cd1f695 Mon Sep 17 00:00:00 2001 From: Alfonso Vasquez Date: Fri, 12 Jun 2026 14:16:55 -0400 Subject: [PATCH 8/8] Adding code to clear Spring contexts before/after site context init --- .../engine/service/context/SiteContext.java | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/craftercms/engine/service/context/SiteContext.java b/src/main/java/org/craftercms/engine/service/context/SiteContext.java index 16025dec2..a58249d6c 100644 --- a/src/main/java/org/craftercms/engine/service/context/SiteContext.java +++ b/src/main/java/org/craftercms/engine/service/context/SiteContext.java @@ -39,6 +39,8 @@ import org.slf4j.MDC; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.view.freemarker.FreeMarkerConfig; import org.tuckey.web.filters.urlrewrite.UrlRewriter; @@ -201,11 +203,47 @@ public SiteContext() { // With this executor maintenance tasks are executed sequentially in the order they're received. This is // important when a cache warm is submitted and a GraphQL re-build needs to wait till the cache warm is // finished - maintenanceTaskExecutor = Executors.newSingleThreadExecutor(); + maintenanceTaskExecutor = createMaintenanceTaskExecutor(); state = State.INITIALIZING; initializationLatch = new CountDownLatch(1); } + private ExecutorService createMaintenanceTaskExecutor() { + ThreadFactory threadFactory = runnable -> { + Thread thread = Executors.defaultThreadFactory().newThread(runnable); + thread.setName("site-context-maintenance-" + thread.threadId()); + return thread; + }; + + return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), threadFactory) { + @Override + protected void beforeExecute(Thread t, Runnable r) { + clearSpringThreadContexts(); + super.beforeExecute(t, r); + } + + @Override + protected void afterExecute(Runnable r, Throwable t) { + try { + super.afterExecute(r, t); + + // Log errors from execute() tasks directly. For submit() tasks, the caller is responsible + // for calling future.get() and handling the exception, so we don't log here to avoid duplicates. + if (t != null) { + logger.error("Error running maintenance task for site '{}'", siteName, t); + } + } finally { + clearSpringThreadContexts(); + } + } + }; + } + + private void clearSpringThreadContexts() { + RequestContextHolder.resetRequestAttributes(); + LocaleContextHolder.resetLocaleContext(); + } + public ContentStoreService getStoreService() { return storeService; }