From b76da8aff2a3ade326da4e989f2614b247410b09 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Fri, 16 Feb 2024 17:06:09 +0100 Subject: [PATCH 1/5] MCR-3046 allow id generation independent from actual storage --- .../MCRFileBaseCacheObjectIDGenerator.java | 173 ++++++++++++++++++ .../frontend/cli/MCRObjectCommands.java | 15 ++ ...MCRFileBaseCacheObjectIDGeneratorTest.java | 58 ++++++ 3 files changed, 246 insertions(+) create mode 100644 mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java create mode 100644 mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java diff --git a/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java b/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java new file mode 100644 index 0000000000..c2cab779fa --- /dev/null +++ b/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java @@ -0,0 +1,173 @@ +package org.mycore.datamodel.common; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.mycore.common.MCRException; +import org.mycore.common.MCRUtils; +import org.mycore.common.config.MCRConfiguration2; +import org.mycore.datamodel.metadata.MCRObjectID; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class MCRFileBaseCacheObjectIDGenerator implements MCRObjectIDGenerator { + + private static final Logger LOGGER = LogManager.getLogger(); + + static ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + private static Path getCacheFilePath(String baseId) { + + Path dataDir = getDataDirPath(); + + Path idCachePath = dataDir.resolve("id_cache"); + if (!Files.exists(idCachePath)) { + synchronized (MCRFileBaseCacheObjectIDGenerator.class) { + if (!Files.exists(idCachePath)) { + try { + Files.createDirectory(idCachePath); + } catch (IOException e) { + throw new MCRException( + "Could not create " + idCachePath.toAbsolutePath() + " directory", e); + } + } + } + } + + Path cacheFile = MCRUtils.safeResolve(idCachePath, baseId); + if (!Files.exists(cacheFile)) { + synchronized (MCRFileBaseCacheObjectIDGenerator.class) { + if (!Files.exists(cacheFile)) { + try { + Files.createFile(cacheFile); + } catch (IOException e) { + throw new MCRException("Could not create " + cacheFile.toAbsolutePath(), e); + } + } + } + } + return cacheFile; + } + + static Path getDataDirPath() { + Path path = Paths.get(MCRConfiguration2.getStringOrThrow("MCR.datadir")); + if (Files.exists(path) && !Files.isDirectory(path)) { + throw new MCRException("Data directory does not exist or is not a directory: " + path); + } + return path; + } + + private static void writeNewID(MCRObjectID nextID, ByteBuffer buffer, FileChannel channel, Path cacheFile) + throws IOException { + buffer.clear(); + channel.position(0); + buffer.putInt(nextID.getNumberAsInteger()); + buffer.flip(); + int written = channel.write(buffer); + if (written != Integer.BYTES) { + throw new MCRException("Could not write new ID to " + cacheFile.toAbsolutePath()); + } + } + + public void setNextFreeId(String baseId, int next) { + Path cacheFile = getCacheFilePath(baseId); + + try ( + FileChannel channel = FileChannel.open(cacheFile, StandardOpenOption.WRITE, + StandardOpenOption.SYNC, StandardOpenOption.CREATE);){ + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + channel.position(0); + writeNewID(MCRObjectID.getInstance(MCRObjectID.formatID(baseId, next-1)), buffer, channel, cacheFile); + } catch (FileNotFoundException e) { + throw new MCRException("Could not create " + cacheFile.toAbsolutePath(), e); + } catch (IOException e) { + throw new MCRException("Could not open " + cacheFile.toAbsolutePath(), e); + } + } + + @Override + public MCRObjectID getNextFreeId(String baseId, int maxInWorkflow) { + Path cacheFile = getCacheFilePath(baseId); + + MCRObjectID nextID; + + ReentrantReadWriteLock lock = locks.computeIfAbsent(baseId, k -> new ReentrantReadWriteLock()); + ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); + + try { + writeLock.lock(); + try ( + FileChannel channel = FileChannel.open(cacheFile, StandardOpenOption.READ, StandardOpenOption.WRITE, + StandardOpenOption.SYNC); + FileLock fileLock = channel.lock()) { + + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + buffer.clear(); + channel.position(0); + int bytesRead = channel.read(buffer); + + if (bytesRead <= 0) { + LOGGER.info("No ID found in " + cacheFile.toAbsolutePath()); + // empty file -> new currentID is 1 + nextID = MCRObjectID.getInstance(MCRObjectID.formatID(baseId, maxInWorkflow + 1)); + writeNewID(nextID, buffer, channel, cacheFile); + } else if (bytesRead == Integer.BYTES) { + buffer.flip(); + int lastID = buffer.getInt(); + nextID = MCRObjectID.getInstance(MCRObjectID.formatID(baseId, lastID + maxInWorkflow + 1)); + writeNewID(nextID, buffer, channel, cacheFile); + } else { + throw new MCRException("Content is not Int Number " + cacheFile.toAbsolutePath()); + } + } catch (FileNotFoundException e) { + throw new MCRException("Could not create " + cacheFile.toAbsolutePath(), e); + } catch (IOException e) { + throw new MCRException("Could not open " + cacheFile.toAbsolutePath(), e); + } + } finally { + writeLock.unlock(); + } + + return nextID; + } + + @Override + public MCRObjectID getLastID(String baseId) { + Path cacheFilePath = getCacheFilePath(baseId); + ReentrantReadWriteLock lock = locks.computeIfAbsent(baseId, k -> new ReentrantReadWriteLock()); + ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); + try { + readLock.lock(); + + try (FileChannel channel = FileChannel.open(cacheFilePath)) { + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + buffer.clear(); + channel.position(0); + int bytesRead = channel.read(buffer); + if (bytesRead == -1) { + return null; + } else if (bytesRead == Integer.BYTES) { + buffer.flip(); + int lastID = buffer.getInt(); + return MCRObjectID.getInstance(MCRObjectID.formatID(baseId, lastID)); + } else { + throw new MCRException("Content is not Int Number " + cacheFilePath.toAbsolutePath()); + } + } catch (IOException e) { + throw new MCRException("Could not open " + cacheFilePath.toAbsolutePath(), e); + } + } finally { + readLock.unlock(); + } + } + +} diff --git a/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java b/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java index 0eea4718a2..35f6e304fd 100644 --- a/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java +++ b/mycore-base/src/main/java/org/mycore/frontend/cli/MCRObjectCommands.java @@ -84,6 +84,7 @@ import org.mycore.common.xsl.MCRErrorListener; import org.mycore.datamodel.common.MCRAbstractMetadataVersion; import org.mycore.datamodel.common.MCRActiveLinkException; +import org.mycore.datamodel.common.MCRFileBaseCacheObjectIDGenerator; import org.mycore.datamodel.common.MCRLinkTableManager; import org.mycore.datamodel.common.MCRXMLMetadataManager; import org.mycore.datamodel.metadata.MCRBase; @@ -1386,6 +1387,20 @@ public static void repairSharedMetadata(String id) throws MCRAccessException { MCRMetadataManager.repairSharedMetadata(obj); } + @MCRCommand( + syntax = "create object id cache", + help = "Creates a cache for all object ids in the configuration directory.", + order = 175) + public static void createObjectIDCache() { + MCRXMLMetadataManager metadataManager = MCRXMLMetadataManager.instance(); + metadataManager.getObjectBaseIds().forEach(id -> { + LOGGER.info("Creating cache for base {}", id); + int highestStoredID = metadataManager.getHighestStoredID(id); + MCRFileBaseCacheObjectIDGenerator gen = new MCRFileBaseCacheObjectIDGenerator(); + gen.setNextFreeId(id, highestStoredID+1); + }); + } + /** * The method start the repair of the metadata search for a given MCRObjectID as String. * diff --git a/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java b/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java new file mode 100644 index 0000000000..98487fa559 --- /dev/null +++ b/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java @@ -0,0 +1,58 @@ +package org.mycore.datamodel.common; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.junit.Test; +import org.mycore.common.MCRTestCase; +import org.mycore.datamodel.metadata.MCRObjectID; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +public class MCRFileBaseCacheObjectIDGeneratorTest extends MCRTestCase { + + public static final int GENERATOR_COUNT = 10; + public static final int TEST_IDS = 100; + + private static final Logger LOGGER = LogManager.getLogger(); + + @Test + public void getNextFreeId() throws IOException { + Files.createDirectories(MCRFileBaseCacheObjectIDGenerator.getDataDirPath()); + + var generatorList = new ArrayList(); + for (int i = 0; i < GENERATOR_COUNT; i++) { + generatorList.add(new MCRFileBaseCacheObjectIDGenerator()); + } + + // need thread safe list of generated ids + var generatedIds = Collections.synchronizedList(new ArrayList()); + IntStream.range(0, TEST_IDS) + .parallel() + .forEach(i -> { + LOGGER.info("Generating ID {}", i); + var generator = generatorList.get(i % GENERATOR_COUNT); + MCRObjectID id = generator.getNextFreeId("junit", "test"); + generatedIds.add(id); + }); + + + // check if all ids are unique + assertEquals(TEST_IDS, generatedIds.size()); + assertEquals(TEST_IDS, generatedIds.stream().distinct().count()); + + // check if there is no space in the ids + var sortedIds = new ArrayList<>(generatedIds); + Collections.sort(sortedIds); + for (int i = 0; i < sortedIds.size() - 1; i++) { + assertEquals(i+1, sortedIds.get(i).getNumberAsInteger()); + } + + } + +} From 9fea7c787aee0f3789522515d2fd3bb3bd0f4788 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Fri, 16 Feb 2024 19:36:27 +0100 Subject: [PATCH 2/5] MCR-3046 removed junit5 code --- .../datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java b/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java index 98487fa559..ec9ea0a5df 100644 --- a/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java +++ b/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java @@ -12,7 +12,7 @@ import java.util.Collections; import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.Assert.assertEquals; public class MCRFileBaseCacheObjectIDGeneratorTest extends MCRTestCase { From 07b9931b745672c3fecc26a16e4a5d667dcaa567 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 20 Feb 2024 11:24:23 +0100 Subject: [PATCH 3/5] MCR-3046 some improvements * store ids as strings to make debugging easier * add a docs to setNextFreeId * add missing copyrights --- .../org/mycore/common/xml/MCRURIResolver.java | 2 +- .../MCRFileBaseCacheObjectIDGenerator.java | 62 +++++++++++++++---- ...MCRFileBaseCacheObjectIDGeneratorTest.java | 18 ++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java b/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java index 4e074d8d7e..9849c652e7 100644 --- a/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java +++ b/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java @@ -351,7 +351,7 @@ public Source resolve(String href, String base) throws TransformerException { URIResolver uriResolver = SUPPORTED_SCHEMES.get(scheme); if (uriResolver != null) { Source resolved = uriResolver.resolve(href, base); - if (resolved.getSystemId() == null) { + if (resolved != null && resolved.getSystemId() == null) { resolved.setSystemId(href); } return resolved; diff --git a/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java b/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java index c2cab779fa..25609a414b 100644 --- a/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java +++ b/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java @@ -1,3 +1,21 @@ +/* + * This file is part of *** M y C o R e *** + * See http://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe 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 MyCoRe. If not, see . + */ + package org.mycore.datamodel.common; import org.apache.logging.log4j.LogManager; @@ -12,6 +30,7 @@ import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -70,21 +89,29 @@ private static void writeNewID(MCRObjectID nextID, ByteBuffer buffer, FileChanne throws IOException { buffer.clear(); channel.position(0); - buffer.putInt(nextID.getNumberAsInteger()); + byte[] idAsBytes = nextID.toString().getBytes(StandardCharsets.UTF_8); + buffer.put(idAsBytes); buffer.flip(); int written = channel.write(buffer); - if (written != Integer.BYTES) { + if (written != idAsBytes.length) { throw new MCRException("Could not write new ID to " + cacheFile.toAbsolutePath()); } } + /** + * Set the next free id for the given baseId. Should only be used for migration purposes and the caller has to make + * sure that the cache file is not used by another process. + * @param baseId the base id + * @param next the next free id to be returned by getNextFreeId + */ public void setNextFreeId(String baseId, int next) { Path cacheFile = getCacheFilePath(baseId); + int idLengthInBytes = MCRObjectID.formatID(baseId, 1).getBytes(StandardCharsets.UTF_8).length; try ( FileChannel channel = FileChannel.open(cacheFile, StandardOpenOption.WRITE, StandardOpenOption.SYNC, StandardOpenOption.CREATE);){ - ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + ByteBuffer buffer = ByteBuffer.allocate(idLengthInBytes); channel.position(0); writeNewID(MCRObjectID.getInstance(MCRObjectID.formatID(baseId, next-1)), buffer, channel, cacheFile); } catch (FileNotFoundException e) { @@ -110,23 +137,24 @@ public MCRObjectID getNextFreeId(String baseId, int maxInWorkflow) { StandardOpenOption.SYNC); FileLock fileLock = channel.lock()) { - ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + int idLengthInBytes = MCRObjectID.formatID(baseId, 1).getBytes(StandardCharsets.UTF_8).length; + ByteBuffer buffer = ByteBuffer.allocate(idLengthInBytes); buffer.clear(); channel.position(0); int bytesRead = channel.read(buffer); - if (bytesRead <= 0) { LOGGER.info("No ID found in " + cacheFile.toAbsolutePath()); // empty file -> new currentID is 1 nextID = MCRObjectID.getInstance(MCRObjectID.formatID(baseId, maxInWorkflow + 1)); writeNewID(nextID, buffer, channel, cacheFile); - } else if (bytesRead == Integer.BYTES) { + } else if (bytesRead == idLengthInBytes) { buffer.flip(); - int lastID = buffer.getInt(); + MCRObjectID objectID = readObjectIDFromBuffer(idLengthInBytes, buffer); + int lastID = objectID.getNumberAsInteger(); nextID = MCRObjectID.getInstance(MCRObjectID.formatID(baseId, lastID + maxInWorkflow + 1)); writeNewID(nextID, buffer, channel, cacheFile); } else { - throw new MCRException("Content is not Int Number " + cacheFile.toAbsolutePath()); + throw new MCRException("Content has different id length " + cacheFile.toAbsolutePath()); } } catch (FileNotFoundException e) { throw new MCRException("Could not create " + cacheFile.toAbsolutePath(), e); @@ -140,6 +168,13 @@ public MCRObjectID getNextFreeId(String baseId, int maxInWorkflow) { return nextID; } + private static MCRObjectID readObjectIDFromBuffer(int idLengthBytes, ByteBuffer buffer) { + byte[] idBytes = new byte[idLengthBytes]; + buffer.get(idBytes); + String lastIDString = new String(idBytes, StandardCharsets.UTF_8); + return MCRObjectID.getInstance(lastIDString); + } + @Override public MCRObjectID getLastID(String baseId) { Path cacheFilePath = getCacheFilePath(baseId); @@ -147,20 +182,21 @@ public MCRObjectID getLastID(String baseId) { ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); try { readLock.lock(); + int idLengthInBytes = MCRObjectID.formatID(baseId, 1).getBytes(StandardCharsets.UTF_8).length; try (FileChannel channel = FileChannel.open(cacheFilePath)) { - ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + ByteBuffer buffer = ByteBuffer.allocate(idLengthInBytes); buffer.clear(); channel.position(0); int bytesRead = channel.read(buffer); if (bytesRead == -1) { + // empty file -> no ID found return null; - } else if (bytesRead == Integer.BYTES) { + } else if (bytesRead == idLengthInBytes) { buffer.flip(); - int lastID = buffer.getInt(); - return MCRObjectID.getInstance(MCRObjectID.formatID(baseId, lastID)); + return readObjectIDFromBuffer(idLengthInBytes, buffer); } else { - throw new MCRException("Content is not Int Number " + cacheFilePath.toAbsolutePath()); + throw new MCRException("Content has different id length " + cacheFilePath.toAbsolutePath()); } } catch (IOException e) { throw new MCRException("Could not open " + cacheFilePath.toAbsolutePath(), e); diff --git a/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java b/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java index ec9ea0a5df..d18c5e0d60 100644 --- a/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java +++ b/mycore-base/src/test/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGeneratorTest.java @@ -1,3 +1,21 @@ +/* + * This file is part of *** M y C o R e *** + * See http://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe 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 MyCoRe. If not, see . + */ + package org.mycore.datamodel.common; import org.apache.logging.log4j.LogManager; From 0bdf35c55ab31e86f6d185a08484116515e218fb Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 20 Feb 2024 11:38:59 +0100 Subject: [PATCH 4/5] MCR-3046 add javadoc --- .../datamodel/common/MCRFileBaseCacheObjectIDGenerator.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java b/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java index 25609a414b..4a52c89d61 100644 --- a/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java +++ b/mycore-base/src/main/java/org/mycore/datamodel/common/MCRFileBaseCacheObjectIDGenerator.java @@ -38,6 +38,11 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantReadWriteLock; +/** + * This class generates object ids based on a file based cache. The cache is used to store the last generated id for a + * given base id. The cache file is located in the data directory of MyCoRe and is named "id_cache" and contains one + * file for each base id. The file contains the last generated id as a string. + */ public class MCRFileBaseCacheObjectIDGenerator implements MCRObjectIDGenerator { private static final Logger LOGGER = LogManager.getLogger(); From 653d144b2206745287a62986aee30dd8587f133e Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 20 Feb 2024 11:42:17 +0100 Subject: [PATCH 5/5] reverted unwanted change --- .../src/main/java/org/mycore/common/xml/MCRURIResolver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java b/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java index 9849c652e7..4e074d8d7e 100644 --- a/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java +++ b/mycore-base/src/main/java/org/mycore/common/xml/MCRURIResolver.java @@ -351,7 +351,7 @@ public Source resolve(String href, String base) throws TransformerException { URIResolver uriResolver = SUPPORTED_SCHEMES.get(scheme); if (uriResolver != null) { Source resolved = uriResolver.resolve(href, base); - if (resolved != null && resolved.getSystemId() == null) { + if (resolved.getSystemId() == null) { resolved.setSystemId(href); } return resolved;