Skip to content

Commit a65adcd

Browse files
committed
Rollback persisted configuration on blob store add/modify failures
When adding or modifying a blob store, if an exception occurs after the configuration has been persisted to disk, the stored XML file could be left in an invalid state that prevents application startup on restart. This prevents GeoServer to store invalid blob store configurations, for as long as the BlobStore throws UnsuitableStorageException, in order not to change the current logic. This fix ensures that if save() or handleAddBlobStore()/handleModifyBlobStore() fail, the persisted configuration is rolled back by re-saving the previous valid state. Makes loadConfiguration() package-private with @VisibleForTesting to allow tests to verify the persisted state matches expectations after rollback.
1 parent 7f498c5 commit a65adcd

File tree

2 files changed

+198
-6
lines changed

2 files changed

+198
-6
lines changed

geowebcache/core/src/main/java/org/geowebcache/config/XMLConfiguration.java

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import static java.nio.charset.StandardCharsets.UTF_8;
1717

18+
import com.google.common.annotations.VisibleForTesting;
1819
import com.thoughtworks.xstream.XStream;
1920
import com.thoughtworks.xstream.io.xml.DomReader;
2021
import java.io.FileNotFoundException;
@@ -290,7 +291,8 @@ public void setDefaultValues(TileLayer layer) {
290291
}
291292
}
292293

293-
private GeoWebCacheConfiguration loadConfiguration() throws ConfigurationException {
294+
@VisibleForTesting
295+
GeoWebCacheConfiguration loadConfiguration() throws ConfigurationException {
294296
Assert.isTrue(resourceProvider.hasInput(), "Resource provider must have an input");
295297
try {
296298
try (InputStream in = resourceProvider.in()) {
@@ -969,19 +971,44 @@ public synchronized void addBlobStore(BlobStoreInfo info) {
969971
} catch (IOException ioe) {
970972
// save failed, roll back the add
971973
blobStores.remove(info);
972-
throw new ConfigurationPersistenceException("Unable to add BlobStoreInfo \"%s\"".formatted(info), ioe);
974+
ConfigurationPersistenceException thrown =
975+
new ConfigurationPersistenceException("Unable to add BlobStoreInfo \"%s\"".formatted(info), ioe);
976+
977+
try { // reverting the stored config by re-saving
978+
save();
979+
} catch (IOException suppressed) {
980+
log.log(
981+
Level.WARNING,
982+
"Unable to revert stored configuration after failure to store BlobStore " + info,
983+
suppressed);
984+
thrown.addSuppressed(thrown);
985+
}
986+
throw thrown;
973987
}
974988
try {
975989
blobStoreListeners.safeForEach(listener -> {
976990
listener.handleAddBlobStore(info);
977991
});
978992
} catch (IOException | GeoWebCacheException e) {
993+
ConfigurationPersistenceException thrown;
979994
if (ExceptionUtils.isOrSuppresses(e, UnsuitableStorageException.class)) {
980995
// Can't store here, roll back
981996
blobStores.remove(info);
982-
throw new ConfigurationPersistenceException("Unable to add BlobStoreInfo \"%s\"".formatted(info), e);
997+
thrown = new ConfigurationPersistenceException("Unable to add BlobStoreInfo \"%s\"".formatted(info), e);
998+
try { // reverting the stored config by re-saving
999+
save();
1000+
log.log(Level.CONFIG, "Configuration reverted after failure to add BlobStore " + info);
1001+
} catch (IOException suppress) {
1002+
log.log(
1003+
Level.WARNING,
1004+
"Unexpected error saving the reverted configuration after failure to add BlobStore " + info,
1005+
suppress);
1006+
thrown.addSuppressed(thrown);
1007+
}
1008+
} else {
1009+
thrown = new ConfigurationPersistenceException(e);
9831010
}
984-
throw new ConfigurationPersistenceException(e);
1011+
throw thrown;
9851012
}
9861013
}
9871014

@@ -1050,7 +1077,21 @@ public synchronized void modifyBlobStore(BlobStoreInfo info) {
10501077
// Can't store here, roll back
10511078
blobStores.remove(info);
10521079
blobStores.add(infoToRemove);
1053-
throw new ConfigurationPersistenceException("Unable to modify BlobStoreInfo \"%s\"".formatted(info), e);
1080+
ConfigurationPersistenceException thrown = new ConfigurationPersistenceException(
1081+
"Unable to modify BlobStoreInfo \"%s\"".formatted(info), e);
1082+
try {
1083+
// roll-back stored config, leaving an invalid stored config will prevent application startup
1084+
save();
1085+
log.log(Level.CONFIG, "Configuration reverted after failure to modify BlobStore " + info);
1086+
} catch (IOException suppress) {
1087+
log.log(
1088+
Level.WARNING,
1089+
"Unexpected error saving the reverted configuration after failure to modify BlobStore "
1090+
+ info,
1091+
suppress);
1092+
thrown.addSuppressed(suppress);
1093+
}
1094+
throw thrown;
10541095
}
10551096
throw new ConfigurationPersistenceException(e);
10561097
}

geowebcache/core/src/test/java/org/geowebcache/config/XMLConfigurationTest.java

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import static org.easymock.EasyMock.createMock;
1717
import static org.easymock.EasyMock.expect;
1818
import static org.easymock.EasyMock.replay;
19+
import static org.hamcrest.CoreMatchers.instanceOf;
1920
import static org.hamcrest.MatcherAssert.assertThat;
2021
import static org.hamcrest.Matchers.containsInAnyOrder;
2122
import static org.hamcrest.Matchers.greaterThan;
@@ -31,12 +32,15 @@
3132
import static org.junit.Assert.assertThrows;
3233
import static org.junit.Assert.assertTrue;
3334
import static org.junit.Assert.fail;
35+
import static org.mockito.Mockito.doThrow;
3436
import static org.mockito.Mockito.mock;
3537
import static org.mockito.Mockito.when;
3638

3739
import java.io.File;
3840
import java.io.FileInputStream;
41+
import java.io.FilterOutputStream;
3942
import java.io.IOException;
43+
import java.io.OutputStream;
4044
import java.net.URL;
4145
import java.util.ArrayList;
4246
import java.util.Arrays;
@@ -67,13 +71,16 @@
6771
import org.geowebcache.grid.SRS;
6872
import org.geowebcache.layer.TileLayer;
6973
import org.geowebcache.layer.wms.WMSLayer;
74+
import org.geowebcache.storage.UnsuitableStorageException;
7075
import org.geowebcache.util.TestUtils;
7176
import org.junit.Assume;
7277
import org.junit.Before;
7378
import org.junit.Rule;
7479
import org.junit.Test;
7580
import org.junit.rules.TemporaryFolder;
81+
import org.mockito.Mockito;
7682
import org.springframework.context.ApplicationContext;
83+
import org.springframework.web.context.WebApplicationContext;
7784
import org.xml.sax.SAXParseException;
7885

7986
public class XMLConfigurationTest {
@@ -477,7 +484,7 @@ public void testNoBlobStores() throws Exception {
477484
}
478485

479486
@Test
480-
public void testSaveBlobStores() throws Exception {
487+
public void testAddBlobStores() throws Exception {
481488
FileBlobStoreInfo store1 = new FileBlobStoreInfo();
482489
store1.setName("store1");
483490
store1.setDefault(true);
@@ -517,6 +524,150 @@ public void testSaveBlobStores() throws Exception {
517524
assertEquals(store2, stores.get(1));
518525
}
519526

527+
@Test
528+
public void testAddBlobStoreExceptionSaving() throws Exception {
529+
530+
XMLFileResourceProvider resourceProvider =
531+
new XMLFileResourceProvider(
532+
XMLConfiguration.DEFAULT_CONFIGURATION_FILE_NAME,
533+
(WebApplicationContext) null,
534+
this.configDir.getAbsolutePath(),
535+
null) {
536+
537+
// throw an ioexception the first time close() is called, the second time is the roll-back
538+
private boolean thrown = false;
539+
540+
@Override
541+
public OutputStream out() throws IOException {
542+
OutputStream real = super.out();
543+
return new FilterOutputStream(real) {
544+
545+
@Override
546+
public void close() throws IOException {
547+
real.close();
548+
if (thrown) {
549+
return;
550+
}
551+
thrown = true;
552+
throw new IOException("forced io exception");
553+
}
554+
};
555+
}
556+
};
557+
558+
config = new XMLConfiguration(null, resourceProvider);
559+
config.setGridSetBroker(gridSetBroker);
560+
561+
FileBlobStoreInfo store1 = new FileBlobStoreInfo();
562+
store1.setName("store1");
563+
store1.setDefault(true);
564+
store1.setEnabled(true);
565+
store1.setFileSystemBlockSize(8096);
566+
store1.setBaseDirectory("/tmp/test");
567+
568+
assertThrows(ConfigurationPersistenceException.class, () -> config.addBlobStore(store1));
569+
assertEquals(0, config.getBlobStoreCount());
570+
GeoWebCacheConfiguration configuration = config.loadConfiguration();
571+
assertTrue("store shouldn't be saved", configuration.getBlobStores().isEmpty());
572+
}
573+
574+
/**
575+
* Verifies the blobstore configuration is rolled back from the persisted configuration if a
576+
* {@link BlobStoreConfigurationListener#handleAddBlobStore} throws an {@link UnsuitableStorageException}.
577+
*
578+
* <p>Note I'm not sure why XMLConfiguration.addBlobStore() only rolls-back on UnsuitableStorageException and not on
579+
* IOException or GeoWebCacheException
580+
*/
581+
@Test
582+
public void testAddBlobStoreExceptionFromListener() throws Exception {
583+
FileBlobStoreInfo store1 = new FileBlobStoreInfo();
584+
store1.setName("store1");
585+
store1.setDefault(true);
586+
store1.setEnabled(true);
587+
store1.setFileSystemBlockSize(8096);
588+
store1.setBaseDirectory("/tmp/test");
589+
590+
BlobStoreConfigurationListener listener;
591+
592+
listener = mock(BlobStoreConfigurationListener.class);
593+
doThrow(new UnsuitableStorageException("fake")).when(listener).handleAddBlobStore(Mockito.any());
594+
config.addBlobStoreListener(listener);
595+
596+
assertAddBlobStoreFails(store1, UnsuitableStorageException.class);
597+
598+
// note, I'm not sure why XMLConfiguration.addBlobStore() only rolls-back on UnsuitableStorageException and not
599+
// on IOException or GeoWebCacheException
600+
// doThrow(new IOException("fake")).when(listener).handleAddBlobStore(Mockito.any());
601+
// assertAddBlobStoreFails(store1, IOException.class);
602+
//
603+
// doThrow(new GeoWebCacheException("fake")).when(listener).handleAddBlobStore(Mockito.any());
604+
// assertAddBlobStoreFails(store1, GeoWebCacheException.class);
605+
}
606+
607+
private void assertAddBlobStoreFails(FileBlobStoreInfo store, Class<? extends Exception> expectedCause)
608+
throws ConfigurationException {
609+
ConfigurationPersistenceException expected;
610+
expected = assertThrows(ConfigurationPersistenceException.class, () -> config.addBlobStore(store));
611+
assertThat(expected.getCause(), instanceOf(expectedCause));
612+
assertEquals(0, config.getBlobStoreCount());
613+
GeoWebCacheConfiguration configuration = config.loadConfiguration();
614+
assertTrue("store shouldn't be saved", configuration.getBlobStores().isEmpty());
615+
}
616+
617+
/**
618+
* Verifies the blobstore configuration is rolled back from the persisted configuration if a
619+
* {@link BlobStoreConfigurationListener#handleModifyBlobStore(BlobStoreInfo)} throws an
620+
* {@link UnsuitableStorageException}.
621+
*
622+
* <p>Note I'm not sure why XMLConfiguration.modifyBobstore() only rolls-back on UnsuitableStorageException and not
623+
* on IOException or GeoWebCacheException
624+
*/
625+
@Test
626+
public void testModifyBlobStoreExceptionFromListener() throws Exception {
627+
FileBlobStoreInfo original = new FileBlobStoreInfo();
628+
original.setName("store1");
629+
original.setDefault(true);
630+
original.setEnabled(true);
631+
original.setFileSystemBlockSize(8096);
632+
original.setBaseDirectory("/tmp/test");
633+
634+
config.addBlobStore(original);
635+
636+
BlobStoreConfigurationListener listener;
637+
638+
listener = mock(BlobStoreConfigurationListener.class);
639+
doThrow(new UnsuitableStorageException("fake")).when(listener).handleModifyBlobStore(Mockito.any());
640+
config.addBlobStoreListener(listener);
641+
642+
assertModifyBlobStoreFails(original, UnsuitableStorageException.class);
643+
644+
// note, I'm not sure why XMLConfiguration.addBlobStore() only rolls-back on UnsuitableStorageException and not
645+
// on IOException or GeoWebCacheException
646+
// doThrow(new IOException("fake")).when(listener).handleModifyBlobStore(Mockito.any());
647+
// assertModifyBlobStoreFails(original, IOException.class);
648+
//
649+
// doThrow(new GeoWebCacheException("fake")).when(listener).handleModifyBlobStore(Mockito.any());
650+
// assertModifyBlobStoreFails(original, GeoWebCacheException.class);
651+
}
652+
653+
private void assertModifyBlobStoreFails(FileBlobStoreInfo original, Class<? extends Exception> expectedCause)
654+
throws ConfigurationException {
655+
656+
FileBlobStoreInfo modified = (FileBlobStoreInfo) original.clone();
657+
modified.setBaseDirectory("/tmp/test2");
658+
659+
assertEquals(1, config.getBlobStoreCount());
660+
661+
ConfigurationPersistenceException expected;
662+
expected = assertThrows(ConfigurationPersistenceException.class, () -> config.modifyBlobStore(modified));
663+
assertThat(expected.getCause(), instanceOf(expectedCause));
664+
GeoWebCacheConfiguration reloaded = config.loadConfiguration();
665+
assertEquals(1, reloaded.getBlobStores().size());
666+
667+
BlobStoreInfo stored = reloaded.getBlobStores().get(0);
668+
assertEquals("store shouldn't be saved", original, stored);
669+
}
670+
520671
@Test
521672
public void testSaveCurrentVersion() throws Exception {
522673

0 commit comments

Comments
 (0)