Skip to content

Commit b6cae27

Browse files
committed
add file system storage for repo
1 parent 5a1896e commit b6cae27

2 files changed

Lines changed: 470 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package org.automerge.repo.storage;
2+
3+
import java.io.IOException;
4+
import java.io.UncheckedIOException;
5+
import java.nio.file.DirectoryStream;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.nio.file.StandardCopyOption;
9+
import java.util.ArrayDeque;
10+
import java.util.Collections;
11+
import java.util.Deque;
12+
import java.util.HashMap;
13+
import java.util.Map;
14+
import java.util.Optional;
15+
import java.util.concurrent.CompletableFuture;
16+
import org.automerge.repo.Storage;
17+
import org.automerge.repo.StorageKey;
18+
19+
/**
20+
* Filesystem-backed storage implementation.
21+
*
22+
* Persists Automerge document data to the local filesystem. The first component
23+
* of each {@link StorageKey} is "splayed" into two directory levels by splitting
24+
* at the second character, distributing files across subdirectories to avoid
25+
* filesystem performance issues with large flat directories.
26+
*
27+
* <p>For example, the key {@code ["abc123", "incremental", "hash"]} maps to the
28+
* path {@code <baseDir>/ab/c123/incremental/hash}.
29+
*
30+
* <p>Writes are atomic: data is written to a temporary file in a {@code .temp}
31+
* directory under the base directory, then renamed into place. This keeps
32+
* temporary files out of the data tree so that range scans by other
33+
* implementations sharing the same directory never see in-flight writes.
34+
*
35+
* <p>All operations complete synchronously but return {@link CompletableFuture}
36+
* for API consistency with the {@link Storage} interface.
37+
*
38+
* <p>This implementation is thread-safe for distinct keys. Concurrent writes to
39+
* the same key rely on the filesystem's rename atomicity guarantees.
40+
*/
41+
public class FileSystemStorage implements Storage {
42+
43+
private final Path baseDir;
44+
private final Path tempDir;
45+
46+
/**
47+
* Creates a new FileSystemStorage rooted at the given directory.
48+
*
49+
* The directory will be created if it does not exist.
50+
*
51+
* @param baseDir
52+
* The root directory for storage
53+
* @throws UncheckedIOException
54+
* if the directory cannot be created
55+
*/
56+
public FileSystemStorage(Path baseDir) {
57+
this.baseDir = baseDir;
58+
this.tempDir = baseDir.resolve(".temp");
59+
try {
60+
Files.createDirectories(baseDir);
61+
Files.createDirectories(tempDir);
62+
} catch (IOException e) {
63+
throw new UncheckedIOException("Failed to create storage directory: " + baseDir, e);
64+
}
65+
}
66+
67+
@Override
68+
public CompletableFuture<Optional<byte[]>> load(StorageKey key) {
69+
Path path = keyToPath(key);
70+
if (!Files.isRegularFile(path)) {
71+
return CompletableFuture.completedFuture(Optional.empty());
72+
}
73+
try {
74+
byte[] data = Files.readAllBytes(path);
75+
return CompletableFuture.completedFuture(Optional.of(data));
76+
} catch (IOException e) {
77+
return failedFuture(new UncheckedIOException("Failed to load key: " + key, e));
78+
}
79+
}
80+
81+
@Override
82+
public CompletableFuture<Map<StorageKey, byte[]>> loadRange(StorageKey prefix) {
83+
Path prefixPath = keyToPath(prefix);
84+
if (!Files.isDirectory(prefixPath)) {
85+
return CompletableFuture.completedFuture(Collections.emptyMap());
86+
}
87+
try {
88+
Map<StorageKey, byte[]> result = new HashMap<>();
89+
Deque<PrefixEntry> toVisit = new ArrayDeque<>();
90+
toVisit.push(new PrefixEntry(prefixPath, prefix));
91+
92+
while (!toVisit.isEmpty()) {
93+
PrefixEntry current = toVisit.pop();
94+
try (DirectoryStream<Path> stream = Files.newDirectoryStream(current.path)) {
95+
for (Path entry : stream) {
96+
String filename = entry.getFileName().toString();
97+
String[] parentParts = current.key.getParts();
98+
String[] childParts = new String[parentParts.length + 1];
99+
System.arraycopy(parentParts, 0, childParts, 0, parentParts.length);
100+
childParts[parentParts.length] = filename;
101+
StorageKey childKey = new StorageKey(childParts);
102+
103+
if (Files.isDirectory(entry)) {
104+
toVisit.push(new PrefixEntry(entry, childKey));
105+
} else if (Files.isRegularFile(entry)) {
106+
byte[] data = Files.readAllBytes(entry);
107+
result.put(childKey, data);
108+
}
109+
}
110+
}
111+
}
112+
return CompletableFuture.completedFuture(result);
113+
} catch (IOException e) {
114+
return failedFuture(new UncheckedIOException("Failed to load range for prefix: " + prefix, e));
115+
}
116+
}
117+
118+
@Override
119+
public CompletableFuture<Void> put(StorageKey key, byte[] value) {
120+
Path path = keyToPath(key);
121+
try {
122+
Files.createDirectories(path.getParent());
123+
// Atomic write: write to temp file in .temp dir, then rename.
124+
// Temp files are kept out of the data tree so that loadRange
125+
// never picks them up as real entries.
126+
Path tmp = Files.createTempFile(tempDir, ".automerge-", ".tmp");
127+
try {
128+
Files.write(tmp, value);
129+
Files.move(tmp, path,
130+
StandardCopyOption.REPLACE_EXISTING,
131+
StandardCopyOption.ATOMIC_MOVE);
132+
} catch (IOException e) {
133+
// Clean up temp file on failure
134+
try {
135+
Files.deleteIfExists(tmp);
136+
} catch (IOException suppressed) {
137+
e.addSuppressed(suppressed);
138+
}
139+
throw e;
140+
}
141+
return CompletableFuture.completedFuture(null);
142+
} catch (IOException e) {
143+
return failedFuture(new UncheckedIOException("Failed to put key: " + key, e));
144+
}
145+
}
146+
147+
@Override
148+
public CompletableFuture<Void> delete(StorageKey key) {
149+
Path path = keyToPath(key);
150+
try {
151+
Files.deleteIfExists(path);
152+
return CompletableFuture.completedFuture(null);
153+
} catch (IOException e) {
154+
return failedFuture(new UncheckedIOException("Failed to delete key: " + key, e));
155+
}
156+
}
157+
158+
@SuppressWarnings("unchecked")
159+
private static <T> CompletableFuture<T> failedFuture(Throwable ex) {
160+
CompletableFuture<T> f = new CompletableFuture<>();
161+
f.completeExceptionally(ex);
162+
return f;
163+
}
164+
165+
/**
166+
* Converts a {@link StorageKey} to a filesystem path.
167+
*
168+
* The first key component is splayed: its first two characters become one
169+
* directory level, and the remainder becomes the next. Subsequent components
170+
* map directly to path segments.
171+
*
172+
* @param key
173+
* The storage key
174+
* @return The resolved path under the base directory
175+
*/
176+
Path keyToPath(StorageKey key) {
177+
String[] parts = key.getParts();
178+
Path path = baseDir;
179+
for (int i = 0; i < parts.length; i++) {
180+
if (i == 0) {
181+
// Splay first component: first 2 chars → dir, rest → subdir
182+
String component = parts[i];
183+
int splitAt = Math.min(2, component.length());
184+
path = path.resolve(component.substring(0, splitAt));
185+
if (splitAt < component.length()) {
186+
path = path.resolve(component.substring(splitAt));
187+
}
188+
} else {
189+
path = path.resolve(parts[i]);
190+
}
191+
}
192+
return path;
193+
}
194+
195+
private static class PrefixEntry {
196+
final Path path;
197+
final StorageKey key;
198+
199+
PrefixEntry(Path path, StorageKey key) {
200+
this.path = path;
201+
this.key = key;
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)