From 4a8c47532890d0f5dc242f77c7d45b958fec867f Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 29 Sep 2026 01:09:09 -0400 Subject: [PATCH 1/6] SOLR-18249: Store shard backup metadata atomically --- changelog/unreleased/SOLR-18249.yml | 7 + .../solr/core/backup/ShardBackupMetadata.java | 17 +- .../backup/repository/BackupRepository.java | 15 ++ .../DelegatingBackupRepository.java | 5 + .../repository/LocalFileSystemRepository.java | 22 +++ .../core/backup/ShardBackupMetadataTest.java | 172 ++++++++++++++++++ 6 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 changelog/unreleased/SOLR-18249.yml create mode 100644 solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java diff --git a/changelog/unreleased/SOLR-18249.yml b/changelog/unreleased/SOLR-18249.yml new file mode 100644 index 000000000000..b768ac67dada --- /dev/null +++ b/changelog/unreleased/SOLR-18249.yml @@ -0,0 +1,7 @@ +title: Replace shard backup metadata atomically so a failed overwrite cannot destroy the previous file. +type: fixed +authors: + - name: ICLA pending +links: + - name: SOLR-18249 + url: https://issues.apache.org/jira/browse/SOLR-18249 diff --git a/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java b/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java index 73e1a13637ad..fda69f23c35b 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java +++ b/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java @@ -17,6 +17,7 @@ package org.apache.solr.core.backup; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -28,7 +29,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import org.apache.lucene.store.IOContext; import org.apache.lucene.store.IndexInput; import org.apache.solr.common.util.Utils; @@ -104,20 +104,15 @@ public static ShardBackupMetadata from( } /** - * Storing ShardBackupMetadata at {@code folderURI} with name {@code filename}. If a file already - * existed there, overwrite it. + * Store this metadata at {@code folderURI} under the shard backup id's filename. An existing file + * is replaced only after the new JSON is fully serialized. */ public void store(BackupRepository repository, URI folderURI, ShardBackupId shardBackupId) throws IOException { final String filename = shardBackupId.getBackupMetadataFilename(); - URI fileURI = repository.resolve(folderURI, filename); - if (repository.exists(fileURI)) { - repository.delete(folderURI, Set.of(filename)); - } - - try (OutputStream os = repository.createOutput(repository.resolve(folderURI, filename))) { - store(os); - } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + store(buffer); + repository.writeAtomically(repository.resolve(folderURI, filename), buffer.toByteArray()); } public Collection listOriginalFileNames() { diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java index b8f86af5e3ed..4b02dda15d9a 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java @@ -147,6 +147,21 @@ default URI resolveDirectory(URI baseUri, String... pathComponents) { */ OutputStream createOutput(URI path) throws IOException; + /** + * Write {@code data} to {@code path}, replacing any existing object only after the new bytes are + * ready to publish. + * + *

The default implementation writes through {@link #createOutput(URI)}. Object-store + * repositories that finalize on close inherit atomic replace as long as callers do not delete the + * destination first. Local filesystems should override this to write a sibling temp file and move + * it into place. + */ + default void writeAtomically(URI path, byte[] data) throws IOException { + try (OutputStream os = createOutput(path)) { + os.write(data); + } + } + // TODO define whether this should also create any nonexistent parent directories. (i.e. is this // 'mkdir', or 'mkdir -p') /** diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java index e3b27cb073c5..2f7aec8890b7 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java @@ -96,6 +96,11 @@ public OutputStream createOutput(URI path) throws IOException { return delegate.createOutput(path); } + @Override + public void writeAtomically(URI path, byte[] data) throws IOException { + delegate.writeAtomically(path, data); + } + @Override public void createDirectory(URI path) throws IOException { delegate.createDirectory(path); diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java index 77f7e1921f37..897319573277 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java @@ -21,12 +21,16 @@ import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.Collection; import java.util.Objects; +import java.util.UUID; import org.apache.commons.io.file.PathUtils; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; @@ -115,6 +119,24 @@ public OutputStream createOutput(URI path) throws IOException { return Files.newOutputStream(Path.of(path)); } + @Override + public void writeAtomically(URI path, byte[] data) throws IOException { + // Write a sibling temp file and move so a crash cannot truncate the destination. + Path dest = Path.of(path); + Path temp = dest.resolveSibling(dest.getFileName().toString() + ".tmp." + UUID.randomUUID()); + try { + Files.write(temp, data, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + try { + Files.move(temp, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, dest, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + Files.deleteIfExists(temp); + throw e; + } + } + @Override public String[] listAll(URI dirPath) throws IOException { // It is better to check the existence of the directory first since diff --git a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java new file mode 100644 index 000000000000..b241b8524254 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.core.backup; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.core.backup.repository.BackupRepository; +import org.apache.solr.core.backup.repository.DelegatingBackupRepository; +import org.apache.solr.core.backup.repository.LocalFileSystemRepository; +import org.junit.Before; +import org.junit.Test; + +/** Unit tests for {@link ShardBackupMetadata} overwrite behavior. */ +public class ShardBackupMetadataTest extends SolrTestCaseJ4 { + + private LocalFileSystemRepository repository; + private URI folder; + private ShardBackupId shardBackupId; + + @Before + public void setUpRepo() throws Exception { + repository = new LocalFileSystemRepository(); + repository.init(new NamedList<>()); + folder = + repository.createURI(createTempDir("shard-backup-metadata").toAbsolutePath().toString()); + repository.createDirectory(folder); + shardBackupId = new ShardBackupId("shard1", BackupId.zero()); + } + + @Test + public void testStoreOverwritesAndReadsBack() throws Exception { + metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + metadata("uniq2", "orig2", new Checksum(2L, 20)).store(repository, folder, shardBackupId); + + ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); + assertNotNull(loaded); + assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); + assertTrue(loaded.getFile("orig2").isPresent()); + assertEquals(2L, loaded.getFile("orig2").get().fileChecksum.checksum); + assertTrue(loaded.getFile("orig1").isEmpty()); + } + + @Test + public void testStoreDoesNotDeleteExistingMetadata() throws Exception { + metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + + RecordingBackupRepository recording = new RecordingBackupRepository(repository); + metadata("uniq2", "orig2", new Checksum(2L, 20)).store(recording, folder, shardBackupId); + + assertTrue("overwrite must not delete the previous metadata file", recording.deleted.isEmpty()); + assertTrue( + "LocalFS writeAtomically writes a sibling temp file instead of createOutput", + recording.created.isEmpty()); + + ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); + assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); + } + + @Test + public void testFailedOverwriteKeepsPreviousMetadata() throws Exception { + metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + + FailingWriteRepository failing = new FailingWriteRepository(repository); + expectThrows( + IOException.class, + () -> + metadata("uniq2", "orig2", new Checksum(2L, 20)).store(failing, folder, shardBackupId)); + + URI dest = repository.resolve(folder, shardBackupId.getBackupMetadataFilename()); + assertTrue(repository.exists(dest)); + ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); + assertNotNull(loaded); + assertEquals(List.of("uniq1"), loaded.listUniqueFileNames()); + assertTrue(loaded.getFile("orig1").isPresent()); + assertTrue(loaded.getFile("orig2").isEmpty()); + } + + @Test + public void testDefaultWriteAtomicallyUsesCreateOutputAndSkipsDelete() throws Exception { + metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + + DefaultWriteRecordingRepository recording = new DefaultWriteRecordingRepository(repository); + metadata("uniq2", "orig2", new Checksum(2L, 20)).store(recording, folder, shardBackupId); + + assertTrue(recording.deleted.isEmpty()); + assertEquals(1, recording.created.size()); + assertEquals( + repository.resolve(folder, shardBackupId.getBackupMetadataFilename()), + recording.created.get(0)); + + ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); + assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); + } + + private static ShardBackupMetadata metadata( + String uniqueFileName, String originalFileName, Checksum checksum) { + ShardBackupMetadata created = ShardBackupMetadata.empty(); + created.addBackedFile(uniqueFileName, originalFileName, checksum); + return created; + } + + private static class RecordingBackupRepository extends DelegatingBackupRepository { + final List deleted = new ArrayList<>(); + final List created = new ArrayList<>(); + + RecordingBackupRepository(BackupRepository delegate) { + setDelegate(delegate); + } + + @Override + public OutputStream createOutput(URI path) throws IOException { + created.add(path); + return super.createOutput(path); + } + + @Override + public void delete(URI path, Collection files) throws IOException { + for (String file : files) { + deleted.add(resolve(path, file)); + } + super.delete(path, files); + } + } + + private static class FailingWriteRepository extends DelegatingBackupRepository { + FailingWriteRepository(BackupRepository delegate) { + setDelegate(delegate); + } + + @Override + public void writeAtomically(URI path, byte[] data) throws IOException { + throw new IOException("injected write failure"); + } + } + + /** + * Uses the interface default {@code writeAtomically} so the test can observe {@code createOutput} + * instead of the LocalFS temp-file override. + */ + private static class DefaultWriteRecordingRepository extends RecordingBackupRepository { + DefaultWriteRecordingRepository(BackupRepository delegate) { + super(delegate); + } + + @Override + public void writeAtomically(URI path, byte[] data) throws IOException { + try (OutputStream os = createOutput(path)) { + os.write(data); + } + } + } +} From c7e5c41e7bd454167b73fc9c1069a0663d8383ce Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 29 Sep 2026 21:05:05 -0400 Subject: [PATCH 2/6] SOLR-18249: Set changelog author --- changelog/unreleased/SOLR-18249.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-18249.yml b/changelog/unreleased/SOLR-18249.yml index b768ac67dada..22b92d329491 100644 --- a/changelog/unreleased/SOLR-18249.yml +++ b/changelog/unreleased/SOLR-18249.yml @@ -1,7 +1,7 @@ title: Replace shard backup metadata atomically so a failed overwrite cannot destroy the previous file. type: fixed authors: - - name: ICLA pending + - name: Nick Shanin links: - name: SOLR-18249 url: https://issues.apache.org/jira/browse/SOLR-18249 From b9b637c9f1cc760ab9a936a8084709ded334037a Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Wed, 30 Sep 2026 02:11:31 +0000 Subject: [PATCH 3/6] SOLR-18249: Fail closed when atomic moves are unavailable LocalFileSystemRepository stages metadata in a sibling file and requests an ATOMIC_MOVE only. It no longer retries with a plain move when atomic publication is unsupported. After an I/O or runtime failure during staging or publication, it attempts to remove the temp file; cleanup failures are retained as suppressed exceptions on the original failure. This is not a portable atomic-replacement guarantee: Java NIO leaves replacement of an existing target provider-specific even when ATOMIC_MOVE is supported. The Javadocs now describe that limit and clarify that BackupRepository's createOutput-based default provides no generic atomicity or failure-preservation guarantee. The SOLR-18249 changelog describes staged publication without promising portable atomic replacement. The interface-default test now invokes BackupRepository's actual default method and records its createOutput call. A LocalFS publication-failure test injects AtomicMoveNotSupportedException and checks byte-for-byte preservation and readability of existing metadata plus temp-file cleanup. Verification: core spotlessJavaCheck passed; focused ShardBackupMetadataTest passed all 5 tests using single-source compilation to work around workspace inode pressure. --- changelog/unreleased/SOLR-18249.yml | 2 +- .../solr/core/backup/ShardBackupMetadata.java | 5 +- .../backup/repository/BackupRepository.java | 11 ++- .../repository/LocalFileSystemRepository.java | 28 ++++++-- .../core/backup/ShardBackupMetadataTest.java | 71 ++++++++++++++----- 5 files changed, 85 insertions(+), 32 deletions(-) diff --git a/changelog/unreleased/SOLR-18249.yml b/changelog/unreleased/SOLR-18249.yml index 22b92d329491..9fe056a8b766 100644 --- a/changelog/unreleased/SOLR-18249.yml +++ b/changelog/unreleased/SOLR-18249.yml @@ -1,4 +1,4 @@ -title: Replace shard backup metadata atomically so a failed overwrite cannot destroy the previous file. +title: Stage shard backup metadata before publication and fail rather than fall back to a non-atomic local move. type: fixed authors: - name: Nick Shanin diff --git a/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java b/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java index fda69f23c35b..9ff78884937e 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java +++ b/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java @@ -104,8 +104,9 @@ public static ShardBackupMetadata from( } /** - * Store this metadata at {@code folderURI} under the shard backup id's filename. An existing file - * is replaced only after the new JSON is fully serialized. + * Store this metadata at {@code folderURI} under the shard backup id's filename. The JSON is + * serialized completely before publication is attempted; atomicity and failure behavior depend on + * the repository's {@link BackupRepository#writeAtomically(URI, byte[])} implementation. */ public void store(BackupRepository repository, URI folderURI, ShardBackupId shardBackupId) throws IOException { diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java index 4b02dda15d9a..65bc7da1bbe7 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java @@ -148,13 +148,12 @@ default URI resolveDirectory(URI baseUri, String... pathComponents) { OutputStream createOutput(URI path) throws IOException; /** - * Write {@code data} to {@code path}, replacing any existing object only after the new bytes are - * ready to publish. + * Write {@code data} to {@code path} using this repository's output semantics. * - *

The default implementation writes through {@link #createOutput(URI)}. Object-store - * repositories that finalize on close inherit atomic replace as long as callers do not delete the - * destination first. Local filesystems should override this to write a sibling temp file and move - * it into place. + *

The default implementation writes directly through {@link #createOutput(URI)}. It does not + * itself stage the bytes or guarantee atomic publication or preservation of an existing object if + * writing fails. Any such guarantees depend on the concrete repository's {@code createOutput} + * implementation or an override of this method. */ default void writeAtomically(URI path, byte[] data) throws IOException { try (OutputStream os = createOutput(path)) { diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java index 897319573277..36911663764f 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java @@ -21,7 +21,6 @@ import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.NoSuchFileException; @@ -119,24 +118,39 @@ public OutputStream createOutput(URI path) throws IOException { return Files.newOutputStream(Path.of(path)); } + /** + * Stage the bytes in a sibling file and ask the filesystem provider to publish it with an atomic + * move. + * + *

This method does not fall back to a non-atomic move. If the provider cannot perform the + * atomic move, the failure is propagated and cleanup of the staged file is attempted. Java NIO + * leaves replacement of an existing target provider-specific even when an atomic move is + * supported, so this method does not promise portable atomic replacement. + * + * @throws IOException if writing or the requested atomic move fails + */ @Override public void writeAtomically(URI path, byte[] data) throws IOException { - // Write a sibling temp file and move so a crash cannot truncate the destination. Path dest = Path.of(path); Path temp = dest.resolveSibling(dest.getFileName().toString() + ".tmp." + UUID.randomUUID()); try { Files.write(temp, data, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + moveAtomically(temp, dest); + } catch (IOException | RuntimeException e) { try { - Files.move(temp, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException e) { - Files.move(temp, dest, StandardCopyOption.REPLACE_EXISTING); + Files.deleteIfExists(temp); + } catch (IOException | RuntimeException cleanupFailure) { + e.addSuppressed(cleanupFailure); } - } catch (IOException e) { - Files.deleteIfExists(temp); throw e; } } + /** Performs the atomic move used by {@link #writeAtomically(URI, byte[])}. */ + protected void moveAtomically(Path temp, Path dest) throws IOException { + Files.move(temp, dest, StandardCopyOption.ATOMIC_MOVE); + } + @Override public String[] listAll(URI dirPath) throws IOException { // It is better to check the existence of the directory first since diff --git a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java index b241b8524254..5fe69fb69073 100644 --- a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java +++ b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java @@ -18,7 +18,13 @@ import java.io.IOException; import java.io.OutputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; import java.net.URI; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -96,11 +102,35 @@ public void testFailedOverwriteKeepsPreviousMetadata() throws Exception { } @Test - public void testDefaultWriteAtomicallyUsesCreateOutputAndSkipsDelete() throws Exception { + public void testUnsupportedAtomicMovePreservesExistingMetadataAndCleansTempFile() + throws Exception { metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + URI dest = repository.resolve(folder, shardBackupId.getBackupMetadataFilename()); + byte[] previousMetadata = Files.readAllBytes(Path.of(dest)); - DefaultWriteRecordingRepository recording = new DefaultWriteRecordingRepository(repository); - metadata("uniq2", "orig2", new Checksum(2L, 20)).store(recording, folder, shardBackupId); + UnsupportedAtomicMoveRepository unsupported = new UnsupportedAtomicMoveRepository(); + unsupported.init(new NamedList<>()); + expectThrows( + AtomicMoveNotSupportedException.class, + () -> + metadata("uniq2", "orig2", new Checksum(2L, 20)) + .store(unsupported, folder, shardBackupId)); + + assertArrayEquals(previousMetadata, Files.readAllBytes(Path.of(dest))); + String[] files = repository.listAll(folder); + assertEquals("the failed publication must not leave its sibling temp file", 1, files.length); + assertEquals(shardBackupId.getBackupMetadataFilename(), files[0]); + ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); + assertEquals(List.of("uniq1"), loaded.listUniqueFileNames()); + } + + @Test + public void testInterfaceDefaultWriteAtomicallyUsesCreateOutput() throws Exception { + metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + + RecordingBackupRepository recording = new RecordingBackupRepository(repository); + BackupRepository interfaceDefault = usingInterfaceDefaultWriteAtomically(recording); + metadata("uniq2", "orig2", new Checksum(2L, 20)).store(interfaceDefault, folder, shardBackupId); assertTrue(recording.deleted.isEmpty()); assertEquals(1, recording.created.size()); @@ -112,6 +142,24 @@ public void testDefaultWriteAtomicallyUsesCreateOutputAndSkipsDelete() throws Ex assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); } + private static BackupRepository usingInterfaceDefaultWriteAtomically( + RecordingBackupRepository recording) { + return (BackupRepository) + Proxy.newProxyInstance( + BackupRepository.class.getClassLoader(), + new Class[] {BackupRepository.class}, + (proxy, method, args) -> { + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + try { + return method.invoke(recording, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + } + private static ShardBackupMetadata metadata( String uniqueFileName, String originalFileName, Checksum checksum) { ShardBackupMetadata created = ShardBackupMetadata.empty(); @@ -153,20 +201,11 @@ public void writeAtomically(URI path, byte[] data) throws IOException { } } - /** - * Uses the interface default {@code writeAtomically} so the test can observe {@code createOutput} - * instead of the LocalFS temp-file override. - */ - private static class DefaultWriteRecordingRepository extends RecordingBackupRepository { - DefaultWriteRecordingRepository(BackupRepository delegate) { - super(delegate); - } - + private static class UnsupportedAtomicMoveRepository extends LocalFileSystemRepository { @Override - public void writeAtomically(URI path, byte[] data) throws IOException { - try (OutputStream os = createOutput(path)) { - os.write(data); - } + protected void moveAtomically(Path temp, Path dest) throws IOException { + throw new AtomicMoveNotSupportedException( + temp.toString(), dest.toString(), "injected unsupported atomic move"); } } } From 59f6d6df3d7a208fb526272d5a35b95db873d9cd Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 29 Sep 2026 20:34:04 -0600 Subject: [PATCH 4/6] SOLR-18249: Convert ShardBackupMetadataTest to SolrTestCase --- .../org/apache/solr/core/backup/ShardBackupMetadataTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java index 5fe69fb69073..06d83ba1ef81 100644 --- a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java +++ b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java @@ -28,7 +28,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.SolrTestCase; import org.apache.solr.common.util.NamedList; import org.apache.solr.core.backup.repository.BackupRepository; import org.apache.solr.core.backup.repository.DelegatingBackupRepository; @@ -37,7 +37,7 @@ import org.junit.Test; /** Unit tests for {@link ShardBackupMetadata} overwrite behavior. */ -public class ShardBackupMetadataTest extends SolrTestCaseJ4 { +public class ShardBackupMetadataTest extends SolrTestCase { private LocalFileSystemRepository repository; private URI folder; From 328912341837cdd6b1d3af23fdda080ce096c627 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 29 Sep 2026 21:11:46 -0600 Subject: [PATCH 5/6] SOLR-18249: Rename writeAtomically to writeBytes, replace existing file atomically The interface default writes directly through createOutput with no atomicity guarantee, so naming it writeAtomically over-promises. Rename to writeBytes and document that atomicity depends on the override. Pass REPLACE_EXISTING alongside ATOMIC_MOVE so repeated publication over an existing metadata file replaces it atomically instead of failing with FileAlreadyExistsException on platforms where an atomic rename does not replace. --- .../solr/core/backup/ShardBackupMetadata.java | 6 +++--- .../backup/repository/BackupRepository.java | 10 +++++----- .../repository/DelegatingBackupRepository.java | 4 ++-- .../repository/LocalFileSystemRepository.java | 14 ++++++-------- .../core/backup/ShardBackupMetadataTest.java | 17 +++++++++++------ 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java b/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java index 9ff78884937e..a5256323ea19 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java +++ b/solr/core/src/java/org/apache/solr/core/backup/ShardBackupMetadata.java @@ -105,15 +105,15 @@ public static ShardBackupMetadata from( /** * Store this metadata at {@code folderURI} under the shard backup id's filename. The JSON is - * serialized completely before publication is attempted; atomicity and failure behavior depend on - * the repository's {@link BackupRepository#writeAtomically(URI, byte[])} implementation. + * serialized completely before the repository is asked to write it; whether publication is atomic + * depends on the repository's {@link BackupRepository#writeBytes(URI, byte[])} implementation. */ public void store(BackupRepository repository, URI folderURI, ShardBackupId shardBackupId) throws IOException { final String filename = shardBackupId.getBackupMetadataFilename(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); store(buffer); - repository.writeAtomically(repository.resolve(folderURI, filename), buffer.toByteArray()); + repository.writeBytes(repository.resolve(folderURI, filename), buffer.toByteArray()); } public Collection listOriginalFileNames() { diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java index 65bc7da1bbe7..8b0c58cc81a4 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/BackupRepository.java @@ -150,12 +150,12 @@ default URI resolveDirectory(URI baseUri, String... pathComponents) { /** * Write {@code data} to {@code path} using this repository's output semantics. * - *

The default implementation writes directly through {@link #createOutput(URI)}. It does not - * itself stage the bytes or guarantee atomic publication or preservation of an existing object if - * writing fails. Any such guarantees depend on the concrete repository's {@code createOutput} - * implementation or an override of this method. + *

The default implementation writes directly through {@link #createOutput(URI)} and makes no + * atomicity guarantee: it does not stage the bytes, and a failed write may leave a partially + * written or replaced object. Repositories whose backing store supports it may override this + * method to stage the bytes and publish them atomically. */ - default void writeAtomically(URI path, byte[] data) throws IOException { + default void writeBytes(URI path, byte[] data) throws IOException { try (OutputStream os = createOutput(path)) { os.write(data); } diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java index 2f7aec8890b7..407998eec63a 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/DelegatingBackupRepository.java @@ -97,8 +97,8 @@ public OutputStream createOutput(URI path) throws IOException { } @Override - public void writeAtomically(URI path, byte[] data) throws IOException { - delegate.writeAtomically(path, data); + public void writeBytes(URI path, byte[] data) throws IOException { + delegate.writeBytes(path, data); } @Override diff --git a/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java b/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java index 36911663764f..f4606fac6315 100644 --- a/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java +++ b/solr/core/src/java/org/apache/solr/core/backup/repository/LocalFileSystemRepository.java @@ -119,18 +119,16 @@ public OutputStream createOutput(URI path) throws IOException { } /** - * Stage the bytes in a sibling file and ask the filesystem provider to publish it with an atomic - * move. + * Stage the bytes in a sibling file and publish them by moving the staged file onto {@code path} + * atomically, replacing any existing file. * *

This method does not fall back to a non-atomic move. If the provider cannot perform the - * atomic move, the failure is propagated and cleanup of the staged file is attempted. Java NIO - * leaves replacement of an existing target provider-specific even when an atomic move is - * supported, so this method does not promise portable atomic replacement. + * atomic move, the failure is propagated and cleanup of the staged file is attempted. * * @throws IOException if writing or the requested atomic move fails */ @Override - public void writeAtomically(URI path, byte[] data) throws IOException { + public void writeBytes(URI path, byte[] data) throws IOException { Path dest = Path.of(path); Path temp = dest.resolveSibling(dest.getFileName().toString() + ".tmp." + UUID.randomUUID()); try { @@ -146,9 +144,9 @@ public void writeAtomically(URI path, byte[] data) throws IOException { } } - /** Performs the atomic move used by {@link #writeAtomically(URI, byte[])}. */ + /** Performs the atomic move used by {@link #writeBytes(URI, byte[])}. */ protected void moveAtomically(Path temp, Path dest) throws IOException { - Files.move(temp, dest, StandardCopyOption.ATOMIC_MOVE); + Files.move(temp, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } @Override diff --git a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java index 06d83ba1ef81..7b8292002a27 100644 --- a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java +++ b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java @@ -26,6 +26,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.List; import org.apache.solr.SolrTestCase; @@ -75,7 +76,7 @@ public void testStoreDoesNotDeleteExistingMetadata() throws Exception { assertTrue("overwrite must not delete the previous metadata file", recording.deleted.isEmpty()); assertTrue( - "LocalFS writeAtomically writes a sibling temp file instead of createOutput", + "LocalFS writeBytes writes a sibling temp file instead of createOutput", recording.created.isEmpty()); ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); @@ -118,18 +119,22 @@ public void testUnsupportedAtomicMovePreservesExistingMetadataAndCleansTempFile( assertArrayEquals(previousMetadata, Files.readAllBytes(Path.of(dest))); String[] files = repository.listAll(folder); - assertEquals("the failed publication must not leave its sibling temp file", 1, files.length); + assertEquals( + "the failed publication must not leave its sibling temp file, found: " + + Arrays.toString(files), + 1, + files.length); assertEquals(shardBackupId.getBackupMetadataFilename(), files[0]); ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); assertEquals(List.of("uniq1"), loaded.listUniqueFileNames()); } @Test - public void testInterfaceDefaultWriteAtomicallyUsesCreateOutput() throws Exception { + public void testInterfaceDefaultWriteBytesUsesCreateOutput() throws Exception { metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); RecordingBackupRepository recording = new RecordingBackupRepository(repository); - BackupRepository interfaceDefault = usingInterfaceDefaultWriteAtomically(recording); + BackupRepository interfaceDefault = usingInterfaceDefaultWriteBytes(recording); metadata("uniq2", "orig2", new Checksum(2L, 20)).store(interfaceDefault, folder, shardBackupId); assertTrue(recording.deleted.isEmpty()); @@ -142,7 +147,7 @@ public void testInterfaceDefaultWriteAtomicallyUsesCreateOutput() throws Excepti assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); } - private static BackupRepository usingInterfaceDefaultWriteAtomically( + private static BackupRepository usingInterfaceDefaultWriteBytes( RecordingBackupRepository recording) { return (BackupRepository) Proxy.newProxyInstance( @@ -196,7 +201,7 @@ private static class FailingWriteRepository extends DelegatingBackupRepository { } @Override - public void writeAtomically(URI path, byte[] data) throws IOException { + public void writeBytes(URI path, byte[] data) throws IOException { throw new IOException("injected write failure"); } } From c4cd38c7bd3415d02c7f4c138b93011de2a03d41 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 4 Oct 2026 22:04:53 +0000 Subject: [PATCH 6/6] SOLR-18249: skip non-metadata files when deleting backups; split out the base-compatible overwrite test --- .../api/collections/DeleteBackupCmd.java | 18 ++-- .../api/collections/DeleteBackupCmdTest.java | 66 ++++++++++++++ .../ShardBackupMetadataOverwriteTest.java | 89 +++++++++++++++++++ .../core/backup/ShardBackupMetadataTest.java | 16 ---- 4 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataOverwriteTest.java diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteBackupCmd.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteBackupCmd.java index 6c0ec870bf11..5c7fd2b43962 100644 --- a/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteBackupCmd.java +++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/DeleteBackupCmd.java @@ -27,7 +27,6 @@ import java.net.URI; import java.nio.file.NoSuchFileException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -166,10 +165,19 @@ void deleteBackupIds( Set referencedIndexFiles = new HashSet<>(); List shardBackupIdFileDeletes = new ArrayList<>(); - List shardBackupIds = - Arrays.stream(repository.listAllOrEmpty(shardBackupMetadataDir)) - .map(sbi -> ShardBackupId.fromShardMetadataFilename(sbi)) - .collect(Collectors.toList()); + List shardBackupIds = new ArrayList<>(); + for (String filename : repository.listAllOrEmpty(shardBackupMetadataDir)) { + try { + shardBackupIds.add(ShardBackupId.fromShardMetadataFilename(filename)); + } catch (IllegalArgumentException e) { + // The directory can hold files that are not shard metadata, such as the staged temp + // file an interrupted metadata write leaves behind. Such files belong to no backup + // point, so they are ignored here instead of failing the whole deletion. + if (log.isDebugEnabled()) { + log.debug("Ignoring file [{}] in shard backup metadata directory", filename); + } + } + } for (ShardBackupId shardBackupId : shardBackupIds) { final BackupId backupId = shardBackupId.getContainingBackupId(); diff --git a/solr/core/src/test/org/apache/solr/cloud/api/collections/DeleteBackupCmdTest.java b/solr/core/src/test/org/apache/solr/cloud/api/collections/DeleteBackupCmdTest.java index 5d01b5ce3af3..f54f766a58a7 100644 --- a/solr/core/src/test/org/apache/solr/cloud/api/collections/DeleteBackupCmdTest.java +++ b/solr/core/src/test/org/apache/solr/cloud/api/collections/DeleteBackupCmdTest.java @@ -17,6 +17,7 @@ package org.apache.solr.cloud.api.collections; import java.io.IOException; +import java.io.OutputStream; import java.net.URI; import java.util.Set; import java.util.UUID; @@ -24,6 +25,9 @@ import org.apache.solr.common.util.NamedList; import org.apache.solr.core.backup.BackupFilePaths; import org.apache.solr.core.backup.BackupId; +import org.apache.solr.core.backup.Checksum; +import org.apache.solr.core.backup.ShardBackupId; +import org.apache.solr.core.backup.ShardBackupMetadata; import org.apache.solr.core.backup.repository.BackupRepository; import org.apache.solr.core.backup.repository.LocalFileSystemRepository; import org.junit.Before; @@ -86,6 +90,68 @@ public void deleteDirectory(URI path) throws IOException { assertEquals("simulated repository failure", thrown.getMessage()); } + @Test + public void testDeleteBackupIdsIgnoresStagedMetadataTempFile() throws Exception { + URI metadataDir = new BackupFilePaths(repository, backupUri).getShardBackupMetadataDir(); + ShardBackupId shardBackupId = new ShardBackupId("shard1", BackupId.zero()); + storeMetadata(metadataDir, shardBackupId); + String stagedFile = createStagedTempFile(metadataDir, shardBackupId); + URI metadataFile = repository.resolve(metadataDir, shardBackupId.getBackupMetadataFilename()); + assertTrue(repository.exists(metadataFile)); + assertTrue(repository.exists(repository.resolve(metadataDir, stagedFile))); + + NamedList results = new NamedList<>(); + new DeleteBackupCmd(null) + .deleteBackupIds(backupUri, repository, Set.of(BackupId.zero()), results); + + assertNotNull(results.get("deleted")); + assertFalse(repository.exists(metadataFile)); + // The staged file is not any backup point's metadata, so it is left in place. + assertTrue(repository.exists(repository.resolve(metadataDir, stagedFile))); + } + + @Test + public void testKeepNumberOfBackupIgnoresStagedMetadataTempFile() throws Exception { + URI metadataDir = new BackupFilePaths(repository, backupUri).getShardBackupMetadataDir(); + ShardBackupId oldest = new ShardBackupId("shard1", BackupId.zero()); + ShardBackupId newest = new ShardBackupId("shard1", new BackupId(1)); + storeMetadata(metadataDir, oldest); + storeMetadata(metadataDir, newest); + createStagedTempFile(metadataDir, oldest); + createBackupPropsFile(BackupId.zero()); + createBackupPropsFile(new BackupId(1)); + + new DeleteBackupCmd(null).keepNumberOfBackup(repository, backupUri, 1, new NamedList<>()); + + assertFalse( + repository.exists(repository.resolve(metadataDir, oldest.getBackupMetadataFilename()))); + assertTrue( + repository.exists(repository.resolve(metadataDir, newest.getBackupMetadataFilename()))); + } + + private void storeMetadata(URI metadataDir, ShardBackupId shardBackupId) throws IOException { + ShardBackupMetadata metadata = ShardBackupMetadata.empty(); + metadata.addBackedFile("uniq_" + shardBackupId.getIdAsString(), "orig", new Checksum(1L, 10)); + metadata.store(repository, metadataDir, shardBackupId); + } + + private String createStagedTempFile(URI metadataDir, ShardBackupId shardBackupId) + throws IOException { + String stagedName = shardBackupId.getBackupMetadataFilename() + ".tmp." + UUID.randomUUID(); + try (OutputStream out = repository.createOutput(repository.resolve(metadataDir, stagedName))) { + out.write('#'); + } + return stagedName; + } + + private void createBackupPropsFile(BackupId backupId) throws IOException { + try (OutputStream out = + repository.createOutput( + repository.resolve(backupUri, BackupFilePaths.getBackupPropsName(backupId)))) { + out.write('#'); + } + } + private URI zkStateDir(BackupId backupId) { return repository.resolveDirectory(backupUri, BackupFilePaths.getZkStateDir(backupId)); } diff --git a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataOverwriteTest.java b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataOverwriteTest.java new file mode 100644 index 000000000000..88a664afd591 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataOverwriteTest.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.core.backup; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.core.backup.repository.BackupRepository; +import org.apache.solr.core.backup.repository.DelegatingBackupRepository; +import org.apache.solr.core.backup.repository.LocalFileSystemRepository; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that overwriting shard backup metadata never deletes the previous metadata file first. + * This test deliberately uses only the {@link BackupRepository} API that predates the {@code + * writeBytes} method, so it also compiles and runs against the code from before that change, where + * the overwrite deleted the existing file and this test fails. + */ +public class ShardBackupMetadataOverwriteTest extends SolrTestCase { + + private LocalFileSystemRepository repository; + private URI folder; + private ShardBackupId shardBackupId; + + @Before + public void setUpRepo() throws Exception { + repository = new LocalFileSystemRepository(); + repository.init(new NamedList<>()); + folder = + repository.createURI(createTempDir("shard-backup-metadata").toAbsolutePath().toString()); + repository.createDirectory(folder); + shardBackupId = new ShardBackupId("shard1", BackupId.zero()); + } + + @Test + public void testStoreDoesNotDeleteExistingMetadata() throws Exception { + metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); + + RecordingBackupRepository recording = new RecordingBackupRepository(repository); + metadata("uniq2", "orig2", new Checksum(2L, 20)).store(recording, folder, shardBackupId); + + assertTrue("overwrite must not delete the previous metadata file", recording.deleted.isEmpty()); + + ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); + assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); + } + + private static ShardBackupMetadata metadata( + String uniqueFileName, String originalFileName, Checksum checksum) { + ShardBackupMetadata created = ShardBackupMetadata.empty(); + created.addBackedFile(uniqueFileName, originalFileName, checksum); + return created; + } + + private static class RecordingBackupRepository extends DelegatingBackupRepository { + final List deleted = new ArrayList<>(); + + RecordingBackupRepository(BackupRepository delegate) { + setDelegate(delegate); + } + + @Override + public void delete(URI path, Collection files) throws IOException { + for (String file : files) { + deleted.add(resolve(path, file)); + } + super.delete(path, files); + } + } +} diff --git a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java index 7b8292002a27..b9819149c104 100644 --- a/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java +++ b/solr/core/src/test/org/apache/solr/core/backup/ShardBackupMetadataTest.java @@ -67,22 +67,6 @@ public void testStoreOverwritesAndReadsBack() throws Exception { assertTrue(loaded.getFile("orig1").isEmpty()); } - @Test - public void testStoreDoesNotDeleteExistingMetadata() throws Exception { - metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId); - - RecordingBackupRepository recording = new RecordingBackupRepository(repository); - metadata("uniq2", "orig2", new Checksum(2L, 20)).store(recording, folder, shardBackupId); - - assertTrue("overwrite must not delete the previous metadata file", recording.deleted.isEmpty()); - assertTrue( - "LocalFS writeBytes writes a sibling temp file instead of createOutput", - recording.created.isEmpty()); - - ShardBackupMetadata loaded = ShardBackupMetadata.from(repository, folder, shardBackupId); - assertEquals(List.of("uniq2"), loaded.listUniqueFileNames()); - } - @Test public void testFailedOverwriteKeepsPreviousMetadata() throws Exception { metadata("uniq1", "orig1", new Checksum(1L, 10)).store(repository, folder, shardBackupId);