From f4dfa54726fe898ee46a56168bc6267efa810d6b Mon Sep 17 00:00:00 2001 From: Gabriel Roldan Date: Sun, 3 Nov 2024 04:10:46 -0300 Subject: [PATCH] Fix a bug where changes to GWC layers won't be propagated to other pods. In vanilla GeoServer, `CatalogConfiguration` is the `TileLayerConfiguration` contributed to the app context to serve `TileLayer`s (`GeoServerTileLayer`) from the GeoServer `Catalog` by means of a `TileLayerCatalog`. This update contributes a different `TileLayerConfiguration` for the same purpose, `GeoServerTileLayerConfiguration`, which is a distributed-event-aware decorator over the actual `CloudCatalogConfiguration` implementation of `TileLayerCatalog`. Since `CloudCatalogConfiguration` is therefore not a Spring bean (to avoid registering it as a delegate to `TileLayerDispatcher`), `TileLayerEvents` need to be relayed from `GeoServerTileLayerConfiguration` to `CloudCatalogConfiguration#onTileLayerEventEvict()`. --- config | 2 +- .../CachingTileLayerInfoRepository.java | 4 + .../PgconfigTileLayerInfoRepository.java | 5 +- .../pgconfig/TileLayerInfoRepository.java | 5 + src/gwc/core/pom.xml | 10 + .../DefaultTileLayerCatalogConfiguration.java | 26 +- .../cloud/gwc/event/TileLayerEvent.java | 21 +- .../repository/CachingTileLayerCatalog.java | 227 ++++++++-------- .../repository/CloudCatalogConfiguration.java | 29 +- .../ForwardingTileLayerCatalog.java | 20 ++ .../GeoServerTileLayerConfiguration.java | 27 ++ .../ResourceStoreTileLayerCatalog.java | 40 +-- .../CachingTileLayerCatalogTest.java | 252 ++++++++++++++++++ .../ResourceStoreTileLayerCatalogTest.java | 154 +++++++++-- 14 files changed, 649 insertions(+), 173 deletions(-) create mode 100644 src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ForwardingTileLayerCatalog.java create mode 100644 src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalogTest.java diff --git a/config b/config index 2247a9405..464a2a300 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 2247a94055f592291a2f9dcf5e9c37b07c8152cc +Subproject commit 464a2a3004ef50b87bf7b3486ea3fc13e0b47a6a diff --git a/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/CachingTileLayerInfoRepository.java b/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/CachingTileLayerInfoRepository.java index 87bc6b921..e257c1619 100644 --- a/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/CachingTileLayerInfoRepository.java +++ b/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/CachingTileLayerInfoRepository.java @@ -20,6 +20,10 @@ import java.util.Set; import java.util.stream.Stream; +/** + * {@link TileLayerInfoRepository} decorator cache {@link TileLayerInfo}s on demand, alleviating the + * load on the delegate, especially under load. + */ @RequiredArgsConstructor @Slf4j(topic = "org.geoserver.cloud.gwc.backend.pgconfig.caching") public class CachingTileLayerInfoRepository implements TileLayerInfoRepository { diff --git a/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/PgconfigTileLayerInfoRepository.java b/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/PgconfigTileLayerInfoRepository.java index 989c5d00b..443131fda 100644 --- a/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/PgconfigTileLayerInfoRepository.java +++ b/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/PgconfigTileLayerInfoRepository.java @@ -11,7 +11,6 @@ import org.geoserver.cloud.backend.pgconfig.catalog.repository.LoggingTemplate; import org.geoserver.gwc.layer.CatalogConfiguration; import org.geoserver.gwc.layer.GeoServerTileLayerInfo; -import org.geoserver.gwc.layer.TileLayerCatalog; import org.geoserver.platform.resource.ResourceStore; import org.springframework.dao.DataAccessException; import org.springframework.dao.EmptyResultDataAccessException; @@ -26,8 +25,8 @@ import java.util.stream.Stream; /** - * Implementation of {@link TileLayerCatalog} for {@link CatalogConfiguration} to manage {@link - * GeoServerTileLayerInfo}s directly from the database instead of going through {@link + * Implementation of {@link TileLayerInfoRepository} for {@link CatalogConfiguration} to manage + * {@link GeoServerTileLayerInfo}s directly from the database instead of going through {@link * ResourceStore}. * * @since 1.7 diff --git a/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/TileLayerInfoRepository.java b/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/TileLayerInfoRepository.java index 9e0d9f0cf..1d3a88439 100644 --- a/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/TileLayerInfoRepository.java +++ b/src/gwc/backends/pgconfig/src/main/java/org/geoserver/cloud/gwc/backend/pgconfig/TileLayerInfoRepository.java @@ -10,6 +10,11 @@ import java.util.Set; import java.util.stream.Stream; +/** + * {@code TileLayerInfoRepository} defines CRUD operations on {@link TileLayerInfo}. + * + * @see PgconfigTileLayerCatalog + */ public interface TileLayerInfoRepository { void add(TileLayerInfo pgInfo) throws DataAccessException; diff --git a/src/gwc/core/pom.xml b/src/gwc/core/pom.xml index 0256e268f..ab4098250 100644 --- a/src/gwc/core/pom.xml +++ b/src/gwc/core/pom.xml @@ -45,5 +45,15 @@ gs-web-gwc true + + org.xmlunit + xmlunit-core + test + + + org.xmlunit + xmlunit-assertj + test + diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/config/core/DefaultTileLayerCatalogConfiguration.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/config/core/DefaultTileLayerCatalogConfiguration.java index b57c58fff..7ff05adb6 100644 --- a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/config/core/DefaultTileLayerCatalogConfiguration.java +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/config/core/DefaultTileLayerCatalogConfiguration.java @@ -6,7 +6,6 @@ import org.geoserver.GeoServerConfigurationLock; import org.geoserver.catalog.Catalog; -import org.geoserver.cloud.gwc.event.TileLayerEvent; import org.geoserver.cloud.gwc.repository.CachingTileLayerCatalog; import org.geoserver.cloud.gwc.repository.CloudCatalogConfiguration; import org.geoserver.cloud.gwc.repository.GeoServerTileLayerConfiguration; @@ -16,8 +15,10 @@ import org.geoserver.gwc.config.GWCConfigPersister; import org.geoserver.gwc.config.GWCInitializer; import org.geoserver.gwc.layer.CatalogConfiguration; +import org.geoserver.gwc.layer.GeoServerTileLayer; import org.geoserver.gwc.layer.TileLayerCatalog; import org.geoserver.platform.resource.ResourceStore; +import org.geowebcache.config.TileLayerConfiguration; import org.geowebcache.grid.GridSetBroker; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cache.CacheManager; @@ -29,7 +30,6 @@ import org.springframework.web.context.WebApplicationContext; import java.util.Optional; -import java.util.function.Consumer; /** * @since 1.0 @@ -57,6 +57,20 @@ DefaultGwcInitializer gwcInitializer( return new DefaultGwcInitializer(configPersister, blobStore, geoseverTileLayers, lock); } + /** + * In vanilla GeoServer, {@link CatalogConfiguration} is the {@link TileLayerConfiguration} + * contributed to the app context to serve {@code TileLayer}s ({@link GeoServerTileLayer}) out + * of the GeoServer {@link Catalog} by means of a {@link TileLayerCatalog}. + * + *

Here we contribute a different {@code TileLayerConfiguration} for the same purpose, {@link + * GeoServerTileLayerConfiguration}, which is a distributed-event aware decorator over the + * actual {@link CloudCatalogConfiguration} implementation of {@code TileLayerCatalog}. + * + *

Since the {@code CloudCatalogConfiguration} isn't hence a spring bean, in order to avoid + * registering as a delegate to {@link TileLayerDispatcher}, {@link TileLayerEvents} will need + * to be relayed from {@code GeoServerTileLayerConfiguration} to {@link + * CloudCatalogConfiguration#onTileLayerEventEvict()}. + */ @SuppressWarnings("java:S6830") @Bean(name = "gwcCatalogConfiguration") GeoServerTileLayerConfiguration gwcCatalogConfiguration( // @@ -66,8 +80,12 @@ GeoServerTileLayerConfiguration gwcCatalogConfiguration( // ApplicationEventPublisher eventPublisher) { var config = new CloudCatalogConfiguration(catalog, tld, gsb); - Consumer gwcEventPublisher = eventPublisher::publishEvent; - return new GeoServerTileLayerConfiguration(config, gwcEventPublisher); + var eventAwareConfig = + new GeoServerTileLayerConfiguration(config, eventPublisher::publishEvent); + // tell GeoServerTileLayerConfiguration to relay TileLayerEvents to + // CloudCatalogConfiguration, since it's not a spring bean can't listen itself. + eventAwareConfig.setEventListener(config::onTileLayerEventEvict); + return eventAwareConfig; } @Primary diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/event/TileLayerEvent.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/event/TileLayerEvent.java index 0d3a8f77e..d799b0d2b 100644 --- a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/event/TileLayerEvent.java +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/event/TileLayerEvent.java @@ -41,11 +41,6 @@ public TileLayerEvent( this.name = layerName; } - public static TileLayerEvent ofId( - @NonNull Object source, @NonNull Type eventType, @NonNull String layerId) { - return new TileLayerEvent(source, eventType, layerId, layerId); - } - public static TileLayerEvent created( @NonNull Object source, @NonNull String publishedId, @NonNull String layerName) { return valueOf(source, Type.CREATED, publishedId, layerName, null); @@ -77,8 +72,20 @@ private static TileLayerEvent valueOf( @Override public String toString() { - return "%s[%s id: %s, name: %s]" - .formatted(getClass().getSimpleName(), getEventType(), getPublishedId(), getName()); + if (null == getOldName()) + return "%s[%s id: %s, name: %s]" + .formatted( + getClass().getSimpleName(), + getEventType(), + getPublishedId(), + getName()); + return "%s[%s id: %s, name: %s, oldname: %s]" + .formatted( + getClass().getSimpleName(), + getEventType(), + getPublishedId(), + getName(), + getOldName()); } protected @Override String getObjectId() { diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalog.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalog.java index 7f5d40704..02c3f4f68 100644 --- a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalog.java +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalog.java @@ -4,138 +4,154 @@ */ package org.geoserver.cloud.gwc.repository; +import com.google.common.base.Stopwatch; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import com.google.common.collect.Maps; + import lombok.NonNull; -import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.geoserver.cloud.gwc.event.GeoWebCacheEvent; import org.geoserver.cloud.gwc.event.TileLayerEvent; import org.geoserver.gwc.layer.GeoServerTileLayerInfo; -import org.geoserver.gwc.layer.TileLayerCatalog; -import org.geoserver.gwc.layer.TileLayerCatalogListener; import org.springframework.cache.Cache; -import org.springframework.cache.Cache.ValueRetrievalException; import org.springframework.cache.CacheManager; import org.springframework.context.event.EventListener; -import java.util.HashSet; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; /** + * Caching decorator for {@link ResourceStoreTileLayerCatalog} using a provided Spring {@link + * CacheManager}. + * + *

Two named {@link Cache caches} are taken from the cache manager, {@code TILE_LAYERS_BY_ID} and + * {@code TILE_LAYERS_BY_NAME}, in order to to point queries by layer id and name respectively. + * + *

{@code CachingTileLayerCatalog} listens to {@link TileLayerEvent}s to evict cache entries for + * modified and removed tile layers. + * * @since 1.0 */ -@RequiredArgsConstructor -public class CachingTileLayerCatalog implements TileLayerCatalog { +@Slf4j(topic = "org.geoserver.cloud.gwc.repository") +public class CachingTileLayerCatalog extends ForwardingTileLayerCatalog { private static final String TILE_LAYERS_BY_ID = "TILE_LAYERS_BY_ID"; - private static final String TILE_LAYERS_BY_NAME = "TILE_LAYERS_BY_NAME"; private final CacheManager cacheManager; - private final ResourceStoreTileLayerCatalog delegate; - private Cache idCache; - private Cache nameCache; - private ConcurrentMap namesById; + Cache idCache; + final BiMap namesById = Maps.synchronizedBiMap(HashBiMap.create()); + + public CachingTileLayerCatalog( + CacheManager cacheManager, ResourceStoreTileLayerCatalog delegate) { + super(delegate); + this.cacheManager = cacheManager; + } @EventListener(TileLayerEvent.class) public void onTileLayerEvent(TileLayerEvent event) { - switch (event.getEventType()) { - case CREATED: - getLayerById(event.getPublishedId()); - break; - case DELETED: - evictById(event.getPublishedId()); - break; - case MODIFIED: - evictById(event.getPublishedId()); - getLayerById(event.getPublishedId()); - break; - default: - throw new IllegalArgumentException( - "Invalid TileLayerEvent type: " + event.getEventType()); - } - } + final String infoId = event.getPublishedId(); - public void evictById(@NonNull String id) { - final String name = namesById.remove(id); - idCache.evict(id); - if (name != null) { - nameCache.evict(name); + if (event.getEventType() == GeoWebCacheEvent.Type.DELETED) { + namesById.remove(infoId); + } else { + namesById.forcePut(infoId, event.getName()); + } + if (evict(infoId)) { + log.debug("Evicted GeoServerTileLayerInfo[{}] upon event {}", infoId, event); + } else { + log.trace( + "Event didn't result in evicting GeoServerTileLayerInfo[{}]: {}", + infoId, + event); } } @Override - public synchronized void initialize() { - delegate.initialize(); - idCache = cacheManager.getCache(TILE_LAYERS_BY_ID); - nameCache = cacheManager.getCache(TILE_LAYERS_BY_NAME); - namesById = new ConcurrentHashMap<>(); - preLoad(); + public Set getLayerNames() { + return Set.copyOf(this.namesById.values()); } @Override - public synchronized void reset() { - if (idCache != null) { - idCache.clear(); - idCache = null; - } - if (nameCache != null) { - nameCache.clear(); - nameCache = null; + public GeoServerTileLayerInfo save(GeoServerTileLayerInfo tl) { + if (evict(tl.getId())) { + log.debug("Preemtively evicted GeoServerTileLayerInfo[{}] on save", tl.getId()); } - if (namesById != null) { - namesById.clear(); - namesById = null; - } - delegate.reset(); + super.save(tl); + GeoServerTileLayerInfo curr = super.getLayerById(tl.getId()); + return cachePut(curr); } - private void preLoad() { - delegate.findAll().forEach(this::onLoaded); + @Override + public GeoServerTileLayerInfo delete(@NonNull String id) { + namesById.remove(id); + idCache.evictIfPresent(id); + return super.delete(id); } - private void onLoaded(@NonNull GeoServerTileLayerInfo info) { - idCache.put(info.getId(), info); - nameCache.put(info.getName(), info); - cacheIdentifiers(info); + private boolean evict(@NonNull String id) { + // note evictIfPresent ought to be used for guaranteed immediate eviction + return null != idCache && idCache.evictIfPresent(id); } - private void cacheIdentifiers(@NonNull GeoServerTileLayerInfo info) { - namesById.put(info.getId(), info.getName()); + @Override + public synchronized void reset() { + if (idCache != null) { + idCache.clear(); + idCache = null; + } + this.namesById.clear(); + super.reset(); } @Override - public void addListener(TileLayerCatalogListener listener) { - delegate.addListener(listener); + public void initialize() { + super.initialize(); + this.idCache = cacheManager.getCache(TILE_LAYERS_BY_ID); + preLoad(); } - @Override - public Set getLayerIds() { - return new HashSet<>(namesById.keySet()); + /** pre-loading makes a real impact when there are several (like in thousands) of tile layers */ + private void preLoad() { + log.info("Caching GeoServerTileLayerInfos from " + delegate.getPersistenceLocation()); + Stopwatch sw = Stopwatch.createStarted(); + ResourceStoreTileLayerCatalog store = (ResourceStoreTileLayerCatalog) delegate; + long count = store.findAll().map(this::cachePut).count(); + log.info("Cached %,d GeoServerTileLayerInfos in %s".formatted(count, sw.stop())); } - @Override - public Set getLayerNames() { - return new HashSet<>(namesById.values()); + private @NonNull GeoServerTileLayerInfo cachePut(@NonNull GeoServerTileLayerInfo info) { + idCache.put(info.getId(), info); + namesById.forcePut(info.getId(), info.getName()); + log.debug("cached GeoServerTileLayerInfo[{}]", info.getName()); + return info; } @Override public String getLayerId(@NonNull String layerName) { - GeoServerTileLayerInfo layer = getLayerByName(layerName); - return layer == null ? null : layer.getId(); + return this.namesById.inverse().get(layerName); } @Override public String getLayerName(@NonNull String layerId) { - return namesById.get(layerId); + String found = this.namesById.get(layerId); + if (null == found) { + getLayerById(layerId); + found = this.namesById.get(layerId); + } + return found; } @Override public GeoServerTileLayerInfo getLayerById(@NonNull String id) { try { - return idCache.get(id, () -> loadLayerById(id)); - } catch (ValueRetrievalException e) { + var tl = idCache.get(id, () -> loadLayerById(id)); + namesById.forcePut(tl.getId(), tl.getName()); + return tl; + } catch (Cache.ValueRetrievalException e) { if (e.getCause() instanceof NoSuchElementException) return null; throw e; } @@ -143,49 +159,36 @@ public GeoServerTileLayerInfo getLayerById(@NonNull String id) { @Override public GeoServerTileLayerInfo getLayerByName(@NonNull String layerName) { - try { - return nameCache.get(layerName, () -> loadLayerByName(layerName)); - } catch (ValueRetrievalException e) { - if (e.getCause() instanceof NoSuchElementException) return null; - throw e; + String id = this.namesById.inverse().get(layerName); + if (null == id) { + try { + var tl = loadLayerByName(layerName); + namesById.forcePut(tl.getId(), tl.getName()); + return tl; + } catch (NoSuchElementException e) { + return null; + } } + return loadLayerById(id); } + /** + * loader function for {@link #getLayerById(String)} + * + * @throws NoSuchElementException to prevent caching a {@code null} value + */ private GeoServerTileLayerInfo loadLayerById(String id) { - GeoServerTileLayerInfo info = delegate.getLayerById(id); - if (info == null) { - throw new NoSuchElementException(id); - } - cacheIdentifiers(info); - return info; + return Optional.ofNullable(super.getLayerById(id)) + .orElseThrow(() -> new NoSuchElementException(id)); } + /** + * loader function for {@link #getLayerByName(String)} + * + * @throws NoSuchElementException to prevent caching a {@code null} value + */ private GeoServerTileLayerInfo loadLayerByName(String name) { - GeoServerTileLayerInfo info = delegate.getLayerByName(name); - if (info == null) { - throw new NoSuchElementException(name); - } - cacheIdentifiers(info); - return info; - } - - @Override - public GeoServerTileLayerInfo delete(@NonNull String tileLayerId) { - return delegate.delete(tileLayerId); - } - - @Override - public GeoServerTileLayerInfo save(@NonNull GeoServerTileLayerInfo newValue) { - return delegate.save(newValue); - } - - @Override - public boolean exists(@NonNull String layerId) { - return delegate.exists(layerId); - } - - @Override - public String getPersistenceLocation() { - return delegate.getPersistenceLocation(); + return Optional.ofNullable(super.getLayerByName(name)) + .orElseThrow(() -> new NoSuchElementException(name)); } } diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CloudCatalogConfiguration.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CloudCatalogConfiguration.java index 76be5e764..052c6a240 100644 --- a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CloudCatalogConfiguration.java +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/CloudCatalogConfiguration.java @@ -9,6 +9,7 @@ import com.google.common.cache.LoadingCache; +import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.reflect.FieldUtils; @@ -56,15 +57,28 @@ public CloudCatalogConfiguration( } } + /** + * Listen to {@link TileLayerEvent}s and clear the cached {@link TileLayer} + * + *

Important: this will only work of this object is a spring bean, if it's not (e.g. used as + * delegate for a decorator), make sure it calls this method as appropriate. + */ @EventListener(TileLayerEvent.class) - public void onTileLayerEvent(TileLayerEvent event) { - log.debug("evicting GeoServerTileLayer cache entry upon {}", event); - spiedLayerCache.invalidate(event.getPublishedId()); + public void onTileLayerEventEvict(TileLayerEvent event) { + String publishedId = event.getPublishedId(); + log.debug("evicting GeoServerTileLayer[{}] cache entry upon {}", publishedId, event); + spiedLayerCache.invalidate(publishedId); + spiedLayerCache.getIfPresent(publishedId); } @Override - public synchronized void addLayer(final TileLayer tl) { - checkNotNull(tl); + public void addLayer(final @NonNull TileLayer tl) { + GeoServerTileLayer tileLayer = checkAddPreconditions(tl); + completeWithDefaults(tileLayer); + super.addLayer(tl); + } + + private GeoServerTileLayer checkAddPreconditions(@NonNull TileLayer tl) { checkArgument(canSave(tl), "Can't save TileLayer of type ", tl.getClass()); GeoServerTileLayer tileLayer = (GeoServerTileLayer) tl; @@ -73,13 +87,16 @@ public synchronized void addLayer(final TileLayer tl) { checkNotNull(info, "GeoServerTileLayerInfo is null"); checkNotNull(info.getId(), "id is null"); checkNotNull(info.getName(), "name is null"); + return tileLayer; + } + private void completeWithDefaults(GeoServerTileLayer tileLayer) { + GeoServerTileLayerInfo info = tileLayer.getInfo(); PublishedInfo publishedInfo = tileLayer.getPublishedInfo(); GWCConfig defaults = GWC.get().getConfig(); GeoServerTileLayerInfo infoDefaults = TileLayerInfoUtil.loadOrCreate(publishedInfo, defaults); setMissingConfig(info, infoDefaults); - super.addLayer(tl); } private void setMissingConfig(GeoServerTileLayerInfo info, GeoServerTileLayerInfo defaults) { diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ForwardingTileLayerCatalog.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ForwardingTileLayerCatalog.java new file mode 100644 index 000000000..d81a7572f --- /dev/null +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ForwardingTileLayerCatalog.java @@ -0,0 +1,20 @@ +/* + * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.gwc.repository; + +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +import org.geoserver.gwc.layer.TileLayerCatalog; + +/** Base class for {@link TileLayerCatalog} decorators */ +@RequiredArgsConstructor +public abstract class ForwardingTileLayerCatalog implements TileLayerCatalog { + + /** Note all {@link TileLayerCatalog} methods are delegated to this subject */ + @Getter @Delegate @NonNull protected final TileLayerCatalog delegate; +} diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/GeoServerTileLayerConfiguration.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/GeoServerTileLayerConfiguration.java index 6f0259df0..539a8e936 100644 --- a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/GeoServerTileLayerConfiguration.java +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/GeoServerTileLayerConfiguration.java @@ -11,6 +11,7 @@ import org.geoserver.gwc.layer.GeoServerTileLayer; import org.geowebcache.config.TileLayerConfiguration; import org.geowebcache.layer.TileLayer; +import org.springframework.context.event.EventListener; import java.util.Optional; import java.util.function.Consumer; @@ -25,14 +26,40 @@ */ public class GeoServerTileLayerConfiguration extends ForwardingTileLayerConfiguration { + /** + * Event publisher, used to send events whenever a {@code TileLayer} is added, changed, deleted. + */ @NonNull private Consumer eventPublisher; + /** + * Consumer of incoming events. + * + * @see #setEventListener + */ + @NonNull private Consumer eventConsumer = e -> {}; + public GeoServerTileLayerConfiguration( @NonNull TileLayerConfiguration subject, Consumer eventPublisher) { super(subject); this.eventPublisher = eventPublisher; } + /** + * Used for client code to set an action to perform when a {@code TileLayerEvent} is received + * instead of published (for example, when received from another cluster node). Can be used for + * example to forward the event to the delegate {@code TileLayerConfiguration} is it can't + * listen to events itself. + */ + public void setEventListener(@NonNull Consumer consumer) { + this.eventConsumer = consumer; + } + + /** Dispatch incoming events to the consumer set in {@link #setEventListener} */ + @EventListener(TileLayerEvent.class) + void onTileLayerEvent(TileLayerEvent event) { + eventConsumer.accept(event); + } + @Override public void addLayer(@NonNull TileLayer tl) throws IllegalArgumentException { super.addLayer(tl); diff --git a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalog.java b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalog.java index 130f664b3..34d0482c7 100644 --- a/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalog.java +++ b/src/gwc/core/src/main/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalog.java @@ -4,6 +4,8 @@ */ package org.geoserver.cloud.gwc.repository; +import static java.util.Objects.requireNonNull; + import com.google.common.base.Preconditions; import com.google.common.collect.Streams; import com.thoughtworks.xstream.XStream; @@ -13,6 +15,7 @@ import lombok.extern.slf4j.Slf4j; import org.geoserver.config.util.SecureXStream; +import org.geoserver.gwc.layer.DefaultTileLayerCatalog; import org.geoserver.gwc.layer.GeoServerTileLayerInfo; import org.geoserver.gwc.layer.TileLayerCatalog; import org.geoserver.gwc.layer.TileLayerCatalogListener; @@ -53,6 +56,12 @@ import java.util.stream.Stream; /** + * {@link TileLayerCatalog} compatible with {@link DefaultTileLayerCatalog} in that it'll read from + * and save to the same XML resources, but instead of caching {@code GeoServerTileLayerInfo}s + * itself, fully delegates to the {@link ResourceStore}, leaving the responsibility of caching + * {@link GeoServerTileLayerInfo}s to the caller to implement through composition with a decorator + * or any other means. + * * @since 1.0 */ @Slf4j(topic = "org.geoserver.cloud.gwc.repository") @@ -68,7 +77,9 @@ public class ResourceStoreTileLayerCatalog implements TileLayerCatalog { */ private final Optional applicationContext; + /** Guards the internal state for {@link #reset()} and {@link #initialize()} to be idempotent */ private final AtomicBoolean initialized = new AtomicBoolean(); + private final List listeners = new CopyOnWriteArrayList<>(); private Supplier xstreamProvider; @@ -99,12 +110,13 @@ public void initialize() { @Override public void addListener(TileLayerCatalogListener listener) { - if (null != listener) listeners.add(listener); + listeners.add(listener); } @Override public Set getLayerIds() { checkInitialized(); + log.debug("getLayerIds..."); try (Stream all = findAll()) { return all.map(GeoServerTileLayerInfo::getId).collect(Collectors.toSet()); } @@ -113,6 +125,7 @@ public Set getLayerIds() { @Override public Set getLayerNames() { checkInitialized(); + log.debug("getLayerNames"); try (Stream all = findAll()) { return all.map(GeoServerTileLayerInfo::getName).collect(Collectors.toSet()); } @@ -121,6 +134,7 @@ public Set getLayerNames() { @Override public String getLayerId(@NonNull String layerName) { checkInitialized(); + log.debug("getLayerId: {}", layerName); try (Stream all = findAll()) { return all.filter(l -> layerName.equals(l.getName())) .map(GeoServerTileLayerInfo::getId) @@ -132,12 +146,10 @@ public String getLayerId(@NonNull String layerName) { @Override public String getLayerName(String layerId) { checkInitialized(); - try (Stream all = findAll()) { - return all.filter(l -> layerId.equals(l.getId())) - .map(GeoServerTileLayerInfo::getName) - .findFirst() - .orElse(null); - } + return findFile(layerId) + .map(this::depersist) + .map(GeoServerTileLayerInfo::getName) + .orElse(null); } @Override @@ -149,6 +161,7 @@ public GeoServerTileLayerInfo getLayerById(@NonNull String id) { @Override public GeoServerTileLayerInfo getLayerByName(String layerName) { checkInitialized(); + log.debug("getLayerByName: {}", layerName); try (Stream all = findAll()) { return all.filter(l -> layerName.equals(l.getName())).findFirst().orElse(null); } @@ -178,8 +191,7 @@ public GeoServerTileLayerInfo delete(@NonNull String tileLayerId) { @Override public GeoServerTileLayerInfo save(@NonNull GeoServerTileLayerInfo newValue) { checkInitialized(); - final String layerId = newValue.getId(); - Objects.requireNonNull(layerId); + final String layerId = requireNonNull(newValue.getId()); final GeoServerTileLayerInfo prev = getLayerById(layerId); persist(newValue); Type eventType = prev == null ? Type.CREATE : Type.MODIFY; @@ -239,8 +251,7 @@ private GeoServerTileLayerInfo depersist(final Resource res) { } private GeoServerTileLayerInfo depersist(final Resource res, final XStream unmarshaller) { - if (log.isDebugEnabled()) - log.debug("Depersisting GeoServerTileLayerInfo from {}", res.path()); + log.debug("Depersisting GeoServerTileLayerInfo from {}", res.path()); return depersist(res.in(), unmarshaller); } @@ -274,10 +285,7 @@ private Stream findAllTileLayerResources() { private Stream findAllTileLayerResources(Path basePath) { final PathMatcher matcher = basePath.getFileSystem().getPathMatcher("glob:**.xml"); DirectoryStream.Filter filter = - path -> { - boolean matches = matcher.matches(path); - return matches && Files.isRegularFile(path); - }; + path -> matcher.matches(path) && Files.isRegularFile(path); DirectoryStream directoryStream; try { directoryStream = Files.newDirectoryStream(basePath, filter); @@ -305,7 +313,7 @@ private void closeSilently(DirectoryStream directoryStream) { } } - private Optional findFile(final String tileLayerId) { + Optional findFile(final String tileLayerId) { Resource resource = getFile(tileLayerId); return Optional.of(resource).filter(r -> r.getType() == Resource.Type.RESOURCE); } diff --git a/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalogTest.java b/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalogTest.java new file mode 100644 index 000000000..46d8df5b2 --- /dev/null +++ b/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/CachingTileLayerCatalogTest.java @@ -0,0 +1,252 @@ +/* + * (c) 2024 Open Source Geospatial Foundation - all rights reserved This code is licensed under the + * GPL 2.0 license, available at the root application directory. + */ +package org.geoserver.cloud.gwc.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.geoserver.cloud.gwc.event.TileLayerEvent; +import org.geoserver.gwc.layer.GWCGeoServerConfigurationProvider; +import org.geoserver.gwc.layer.GeoServerTileLayerInfo; +import org.geoserver.gwc.layer.GeoServerTileLayerInfoImpl; +import org.geoserver.gwc.layer.TileLayerCatalog; +import org.geoserver.gwc.layer.TileLayerCatalogListener; +import org.geoserver.platform.GeoServerResourceLoader; +import org.geoserver.platform.resource.ResourceStore; +import org.geowebcache.config.XMLConfigurationProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.web.context.WebApplicationContext; + +import java.io.File; +import java.util.Map; +import java.util.Optional; + +class CachingTileLayerCatalogTest { + + private CachingTileLayerCatalog caching; + private ResourceStoreTileLayerCatalog catalog; + + private @TempDir File baseDirectory; + private ResourceStore resourceLoader; + + private CacheManager cacheManager; + + @BeforeEach + void beforeEach() { + resourceLoader = new GeoServerResourceLoader(baseDirectory); + new File(baseDirectory, "gwc-layers").mkdir(); + + WebApplicationContext context = mock(WebApplicationContext.class); + + Map configProviders = + Map.of("gs", new GWCGeoServerConfigurationProvider()); + + when(context.getBeansOfType(XMLConfigurationProvider.class)).thenReturn(configProviders); + when(context.getBean("gs")).thenReturn(configProviders.get("gs")); + + Optional webappCtx = Optional.of(context); + catalog = new ResourceStoreTileLayerCatalog(resourceLoader, webappCtx); + catalog.initialize(); + + cacheManager = new CaffeineCacheManager(); + caching = new CachingTileLayerCatalog(cacheManager, catalog); + caching.initialize(); + } + + @Test + public void initialize() { + caching = new CachingTileLayerCatalog(cacheManager, catalog); + assertThat(caching.idCache).isNull(); + assertThat(caching.namesById).isNotNull().isEmpty(); + + add(catalog, "tl1"); + add(catalog, "tl2"); + + caching.initialize(); + assertThat(caching.idCache).isNotNull(); + assertThat(caching.namesById).isNotNull().hasSize(2); + } + + @Test + public void reset() { + + assertThat(caching.idCache).isNotNull(); + add(caching, "tl1"); + add(caching, "tl2"); + assertThat(caching.namesById).isNotNull().hasSize(2); + + caching.reset(); + assertThat(caching.idCache).isNull(); + assertThat(caching.namesById).isNotNull().isEmpty(); + } + + @Test + public void onTileLayerEvent() { + final String origName = "origName"; + final String newName = "newName"; + catalog.addListener( + new TileLayerCatalogListener() { + @Override + public void onEvent(String layerId, Type type) { + TileLayerEvent event = + switch (type) { + case CREATE -> TileLayerEvent.created(this, layerId, origName); + case MODIFY -> TileLayerEvent.modified( + this, layerId, newName, origName); + case DELETE -> TileLayerEvent.deleted(this, layerId, newName); + default -> throw new IllegalStateException(); + }; + caching.onTileLayerEvent(event); + } + }); + + // do crud ops bypassing the caching decorator, expect the events have the desired effect + assertThat(caching.idCache.get("tl1")).isNull(); + var tl1 = add(catalog, "tl1", origName); + assertThat(caching.namesById.get("tl1")) + .as("create event should have added the id->name mapping") + .isNotNull() + .isEqualTo(origName); + assertThat(caching.idCache.get("tl1")).as("create event doesn't cache a value").isNull(); + + // force caching the entry + caching.getLayerById("tl1"); + assertThat(caching.idCache.get("tl1")).isNotNull(); + + tl1.setName(newName); + catalog.save(tl1); + assertThat(caching.namesById.get("tl1")) + .isNotNull() + .as("update event should have updated the id->name mapping") + .isEqualTo(newName); + + assertThat(caching.idCache.get("tl1")).as("update event should have evicted").isNull(); + + assertThat(caching.getLayerById("tl1")) + .isNotNull() + .hasFieldOrPropertyWithValue("name", newName); + + catalog.delete(tl1.getId()); + assertThat(caching.idCache.get("tl1")).as("delete event should have evicted").isNull(); + assertThat(caching.namesById.get("tl1")).isNull(); + } + + @Test + public void getLayerNames() { + add(catalog, "tl1"); + add(catalog, "tl2"); + caching.initialize(); + assertThat(caching.getLayerNames()).isEqualTo(catalog.getLayerNames()); + } + + @Test + public void save() { + add(caching, "tl1"); + var tl = add(caching, "tl2"); + + tl.setEnabled(false); + tl.setMetaTilingX(9); + + caching.save(tl); + + assertThat(catalog.getLayerById(tl.getId())).isNotSameAs(tl).isEqualTo(tl); + assertThat(caching.idCache.get(tl.getId(), GeoServerTileLayerInfo.class)).isEqualTo(tl); + + tl.setName("newname"); + caching.save(tl); + + assertThat(caching.namesById.get(tl.getId())).isEqualTo("newname"); + assertThat(caching.idCache.get(tl.getId(), GeoServerTileLayerInfo.class)).isEqualTo(tl); + assertThat(catalog.getLayerById(tl.getId())).isNotSameAs(tl).isEqualTo(tl); + } + + @Test + public void delete() { + add(caching, "tl1"); + add(caching, "tl2"); + + assertThat(caching.idCache.get("tl1", GeoServerTileLayerInfo.class)).isNotNull(); + assertThat(caching.idCache.get("tl2", GeoServerTileLayerInfo.class)).isNotNull(); + + caching.delete("tl2"); + assertThat(caching.idCache.get("tl2", GeoServerTileLayerInfo.class)).isNull(); + assertThat(catalog.getLayerById("tl2")).isNull(); + assertThat(caching.getLayerById("tl2")).isNull(); + + assertThat(caching.idCache.get("tl1", GeoServerTileLayerInfo.class)).isNotNull(); + assertThat(caching.getLayerById("tl1")).isNotNull(); + } + + @Test + public void getLayerId() { + add(caching, "tl1", "name1"); + add(caching, "tl2", "name2"); + + assertThat(caching.getLayerId("name1")).isEqualTo("tl1"); + assertThat(caching.getLayerId("name2")).isEqualTo("tl2"); + } + + @Test + public void getLayerName() { + // bypass cache + add(catalog, "tl1", "name1"); + add(catalog, "tl2", "name2"); + + assertThat(caching.getLayerName("tl1")).isNotNull().isEqualTo("name1"); + assertThat(caching.getLayerName("tl2")).isNotNull().isEqualTo("name2"); + } + + @Test + public void getLayerById() { + add(catalog, "tl1", "name1"); + add(catalog, "tl2", "name2"); + + assertThat(caching.getLayerById("tl1")) + .isNotNull() + .hasFieldOrPropertyWithValue("name", "name1"); + assertThat(caching.getLayerById("tl3")).isNull(); + } + + @Test + public void getLayerByName() { + // bypass cache + add(catalog, "tl1", "name1"); + add(catalog, "tl2", "name2"); + + assertThat(caching.getLayerByName("name1")) + .isNotNull() + .hasFieldOrPropertyWithValue("id", "tl1"); + + // simulate a cache evicted, id to name mapping exists but layer is not cached + caching.namesById.put("tl2", "name2"); + assertThat(caching.getLayerByName("name2")) + .isNotNull() + .hasFieldOrPropertyWithValue("id", "tl2"); + + assertThat(caching.getLayerByName("name3")).isNull(); + } + + private GeoServerTileLayerInfo add(TileLayerCatalog target, String name) { + return add(target, name, name); + } + + private GeoServerTileLayerInfo add(TileLayerCatalog target, String id, String name) { + GeoServerTileLayerInfo info = create(id, name); + target.save(info); + return info; + } + + private GeoServerTileLayerInfo create(String id, String name) { + GeoServerTileLayerInfo info = new GeoServerTileLayerInfoImpl(); + info.setId(id); + info.setName(name); + return info; + } +} diff --git a/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalogTest.java b/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalogTest.java index 5764e0b2f..1834e6889 100644 --- a/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalogTest.java +++ b/src/gwc/core/src/test/java/org/geoserver/cloud/gwc/repository/ResourceStoreTileLayerCatalogTest.java @@ -4,29 +4,40 @@ */ package org.geoserver.cloud.gwc.repository; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.apache.commons.io.FileUtils; import org.geoserver.catalog.impl.ModificationProxy; +import org.geoserver.gwc.layer.GWCGeoServerConfigurationProvider; import org.geoserver.gwc.layer.GeoServerTileLayerInfo; import org.geoserver.gwc.layer.GeoServerTileLayerInfoImpl; import org.geoserver.gwc.layer.StyleParameterFilter; import org.geoserver.platform.GeoServerResourceLoader; import org.geoserver.platform.resource.ResourceStore; import org.geoserver.util.DimensionWarning.WarningType; +import org.geowebcache.config.XMLConfigurationProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.web.context.WebApplicationContext; +import org.xmlunit.assertj.XmlAssert; +import org.xmlunit.builder.Input; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -46,40 +57,105 @@ class ResourceStoreTileLayerCatalogTest { void setUp() { resourceLoader = new GeoServerResourceLoader(baseDirectory); new File(baseDirectory, "gwc-layers").mkdir(); - Optional webappCtx = Optional.empty(); + + WebApplicationContext context = mock(WebApplicationContext.class); + + Map configProviders = + Map.of("gs", new GWCGeoServerConfigurationProvider()); + + when(context.getBeansOfType(XMLConfigurationProvider.class)).thenReturn(configProviders); + when(context.getBean("gs")).thenReturn(configProviders.get("gs")); + + Optional webappCtx = Optional.of(context); catalog = new ResourceStoreTileLayerCatalog(resourceLoader, webappCtx); catalog.initialize(); } - @Test - void testGetLayerById() { + private GeoServerTileLayerInfo add(String id, String name) { GeoServerTileLayerInfo info = new GeoServerTileLayerInfoImpl(); - info.setId("id1"); - info.setName("name1"); + info.setId(id); + info.setName(name); catalog.save(info); + return info; + } + + @Test + void testGetLayerIds() { + assertThat(catalog.getLayerIds()).isEmpty(); + + add("id1", "layer1"); + assertThat(catalog.getLayerIds()).containsExactly("id1"); + + add("id2", "layer2"); + assertThat(catalog.getLayerIds()).containsExactlyInAnyOrder("id1", "id2"); + } + + @Test + void testGetLayerId() { + assertThat(catalog.getLayerId("layer1")).isNull(); + + add("id1", "layer1"); + add("id2", "layer2"); + + assertThat(catalog.getLayerId("layer1")).isEqualTo("id1"); + assertThat(catalog.getLayerId("layer2")).isEqualTo("id2"); + } + + @Test + void testGetLayerById() { + GeoServerTileLayerInfo info = add("id1", "name1"); GeoServerTileLayerInfo actual = catalog.getLayerById("id1"); actual = ModificationProxy.unwrap(actual); assertEquals(info, actual); } + @Test + void testGetLayerName() { + assertThat(catalog.getLayerName("id1")).isNull(); + + add("id1", "layer1"); + add("id2", "layer2"); + + assertThat(catalog.getLayerName("id1")).isEqualTo("layer1"); + assertThat(catalog.getLayerName("id2")).isEqualTo("layer2"); + } + + @Test + void testGetLayerNames() { + assertThat(catalog.getLayerNames()).isEmpty(); + + add("id1", "layer1"); + assertThat(catalog.getLayerNames()).containsExactly("layer1"); + + add("id2", "layer2"); + assertThat(catalog.getLayerNames()).containsExactlyInAnyOrder("layer1", "layer2"); + } + @Test void testGetLayerByName() { - GeoServerTileLayerInfo info = new GeoServerTileLayerInfoImpl(); - info.setId("id1"); - info.setName("name1"); - catalog.save(info); + GeoServerTileLayerInfo info = add("id1", "name1"); GeoServerTileLayerInfo actual = catalog.getLayerByName("name1"); actual = ModificationProxy.unwrap(actual); assertEquals(info, actual); } @Test - void testDelete() { - GeoServerTileLayerInfo info = new GeoServerTileLayerInfoImpl(); - info.setId("id1"); - info.setName("name1"); - catalog.save(info); + void testExists() { + assertThat(catalog.exists("id1")).isFalse(); + + add("id1", "layer1"); + add("id2", "layer2"); + assertThat(catalog.exists("id1")).isTrue(); + assertThat(catalog.exists("layer1")).isFalse(); + + assertThat(catalog.exists("id2")).isTrue(); + assertThat(catalog.exists("layer2")).isFalse(); + } + + @Test + void testDelete() { + GeoServerTileLayerInfo info = add("id1", "name1"); GeoServerTileLayerInfo actual = catalog.getLayerByName("name1"); actual = ModificationProxy.unwrap(actual); assertEquals(info, actual); @@ -214,7 +290,7 @@ void testEvents() { } @Test - void testSavedXML() { + void testSavedXML() throws IOException { // checking that the persistence looks as expected final GeoServerTileLayerInfo original; { @@ -241,14 +317,44 @@ void testSavedXML() { catalog.save(original); - // File file = new File(baseDirectory, "gwc-layers/id1.xml"); - // String xml = FileUtils.readFileToString(file, StandardCharsets.UTF_8); - // XPathEngine xpath = XMLUnit.newXpathEngine(); - // Document doc = XMLUnit.buildControlDocument(xml); - // // no custom attribute for the class, we set a default - // assertEquals("", xpath.evaluate("//cacheWarningSkips/class", doc)); - // assertEquals("Default", xpath.evaluate("//cacheWarningSkips/warning[1]", doc)); - // assertEquals("Nearest", xpath.evaluate("//cacheWarningSkips/warning[2]", doc)); - // assertEquals("FailedNearest", xpath.evaluate("//cacheWarningSkips/warning[3]", doc)); + String actual = + FileUtils.readFileToString( + new File(baseDirectory, "gwc-layers/id1.xml"), StandardCharsets.UTF_8); + + String expected = + """ + + id1 + false + name2 + + image/gif + + + + 0 + 0 + + 0 + 0 + + + STYLES + + + + + 0 + + Default + Nearest + FailedNearest + + + """; + + XmlAssert.assertThat(Input.fromString(actual)) + .and(Input.fromString(expected)) + .areIdentical(); } }