Skip to content

Commit 24016cd

Browse files
cache: separate files per value, read from file on demand (#823)
1 parent 8854433 commit 24016cd

6 files changed

Lines changed: 53 additions & 190 deletions

File tree

webtau-cache/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
<scope>test</scope>
6565
</dependency>
6666

67+
<dependency>
68+
<groupId>org.testingisdocumenting.webtau</groupId>
69+
<artifactId>webtau-core-groovy</artifactId>
70+
<version>${project.version}</version>
71+
<scope>test</scope>
72+
</dependency>
73+
6774
<dependency>
6875
<groupId>org.testingisdocumenting.webtau</groupId>
6976
<artifactId>webtau-core</artifactId>

webtau-cache/src/main/java/org/testingisdocumenting/webtau/cache/Cache.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,12 @@ public <E> E get(String key) {
4747
return step.execute(StepReportOptions.SKIP_START);
4848
}
4949

50-
public void put(String key, Object value, long expirationTime) {
50+
public void put(String key, Object value) {
5151
WebTauStep step = WebTauStep.createStep(null,
5252
tokenizedMessage(action("caching value"), AS, id(key), COLON, stringValue(value)),
5353
() -> tokenizedMessage(action("cached value"), AS, id(key), COLON, stringValue(value)),
54-
() -> fileBasedCache.put(key, value, expirationTime));
54+
() -> fileBasedCache.put(key, value));
5555

5656
step.execute(StepReportOptions.SKIP_START);
5757
}
58-
59-
public void put(String key, Object value) {
60-
put(key, value, Long.MAX_VALUE);
61-
}
6258
}

webtau-cache/src/main/java/org/testingisdocumenting/webtau/cache/FileBasedCache.java

Lines changed: 18 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -17,112 +17,50 @@
1717

1818
package org.testingisdocumenting.webtau.cache;
1919

20-
import org.testingisdocumenting.webtau.time.Time;
2120
import org.testingisdocumenting.webtau.utils.FileUtils;
2221
import org.testingisdocumenting.webtau.utils.JsonUtils;
2322

23+
import java.io.IOException;
24+
import java.io.UncheckedIOException;
2425
import java.nio.file.Files;
2526
import java.nio.file.Path;
26-
import java.util.LinkedHashMap;
27-
import java.util.Map;
28-
import java.util.concurrent.ConcurrentHashMap;
29-
import java.util.concurrent.atomic.AtomicBoolean;
30-
import java.util.concurrent.atomic.AtomicLong;
3127
import java.util.function.Supplier;
3228

3329
class FileBasedCache {
34-
private static final String VALUE_KEY = "value";
35-
private static final String EXPIRATION_TIME_KEY = "expirationTime";
36-
37-
private final AtomicBoolean isCacheLoaded;
38-
3930
private final Supplier<Path> cachePathSupplier;
40-
private final AtomicLong lastPutTime;
41-
42-
private final Map<String, Object> loadedCache;
4331

32+
/**
33+
* @param cachePathSupplier path supplier, used instead of a direct value to avoid config trigger load
34+
*/
4435
public FileBasedCache(Supplier<Path> cachePathSupplier) {
4536
this.cachePathSupplier = cachePathSupplier;
46-
this.isCacheLoaded = new AtomicBoolean(false);
47-
this.lastPutTime = new AtomicLong(Time.currentTimeMillis());
48-
this.loadedCache = new ConcurrentHashMap<>();
49-
50-
registerFlushCacheOnExit();
5137
}
5238

5339
@SuppressWarnings("unchecked")
5440
public <E> E get(String key) {
55-
loadCacheIfRequired();
56-
Map<String, Object> valueWithMeta = (Map<String, Object>) loadedCache.get(key);
57-
if (valueWithMeta == null) {
58-
return null;
59-
}
60-
61-
Number expirationTime = (Number) valueWithMeta.get(EXPIRATION_TIME_KEY);
62-
if (Time.currentTimeMillis() >= expirationTime.longValue()) {
63-
loadedCache.remove(key);
64-
flushCacheToDiskIfRequired();
41+
Path valuePath = valueFilePathByKeyAndCreateDirIfRequired(key);
42+
if (!Files.exists(valuePath)) {
6543
return null;
6644
}
6745

68-
E result = (E) valueWithMeta.get(VALUE_KEY);
69-
flushCacheToDiskIfRequired();
70-
71-
return result;
72-
}
73-
74-
public void put(String key, Object value, long expirationTime) {
75-
loadCacheIfRequired();
76-
Map<String, Object> valueWithMeta = createValueWithMeta(value, expirationTime);
77-
loadedCache.put(key, valueWithMeta);
78-
79-
flushCacheToDiskIfRequired();
46+
return (E) JsonUtils.deserialize(FileUtils.fileTextContent(valuePath));
8047
}
8148

8249
public void put(String key, Object value) {
83-
put(key, value, Long.MAX_VALUE);
84-
}
85-
86-
private Map<String, Object> createValueWithMeta(Object value, long expirationTime) {
87-
Map<String, Object> result = new LinkedHashMap<>();
88-
result.put(VALUE_KEY, value);
89-
result.put(EXPIRATION_TIME_KEY, expirationTime);
90-
91-
return result;
92-
}
93-
94-
@SuppressWarnings("unchecked")
95-
private void loadCacheIfRequired() {
96-
if (isCacheLoaded.get()) {
97-
return;
98-
}
99-
100-
Path cachePath = cachePathSupplier.get();
101-
if (!Files.exists(cachePath)) {
102-
isCacheLoaded.set(true);
103-
return;
50+
Path valuePath = valueFilePathByKeyAndCreateDirIfRequired(key);
51+
try {
52+
Files.write(valuePath, JsonUtils.serializePrettyPrint(value).getBytes());
53+
} catch (IOException e) {
54+
throw new UncheckedIOException(e);
10455
}
105-
106-
loadedCache.putAll((Map<String, ?>) JsonUtils.deserialize(FileUtils.fileTextContent(cachePath)));
107-
isCacheLoaded.set(true);
10856
}
10957

110-
private void flushCacheToDiskIfRequired() {
111-
long lastTime = lastPutTime.get();
112-
long current = Time.currentTimeMillis();
113-
114-
if (current - lastTime > 10_000) {
115-
flushCacheToDisk();
58+
private Path valueFilePathByKeyAndCreateDirIfRequired(String key) {
59+
Path root = cachePathSupplier.get();
60+
if (!Files.exists(root)) {
61+
FileUtils.createDirs(root);
11662
}
11763

118-
lastPutTime.set(current);
119-
}
120-
121-
private synchronized void flushCacheToDisk() {
122-
FileUtils.writeTextContent(cachePathSupplier.get(), JsonUtils.serializePrettyPrint(loadedCache));
123-
}
124-
125-
private void registerFlushCacheOnExit() {
126-
Runtime.getRuntime().addShutdownHook(new Thread(this::flushCacheToDisk));
64+
return root.resolve(key + ".json");
12765
}
12866
}

webtau-cache/src/test/groovy/org/testingisdocumenting/webtau/cache/FileBasedCacheTest.groovy

Lines changed: 19 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,12 @@
1818
package org.testingisdocumenting.webtau.cache
1919

2020
import org.junit.After
21-
import org.junit.Assert
2221
import org.junit.Test
23-
import org.testingisdocumenting.webtau.time.DummyTimeProvider
2422
import org.testingisdocumenting.webtau.time.Time
25-
import org.testingisdocumenting.webtau.utils.JsonUtils
23+
import org.testingisdocumenting.webtau.utils.FileUtils
2624

2725
import java.nio.file.Files
2826
import java.nio.file.Path
29-
import java.nio.file.Paths
30-
import java.util.concurrent.Executors
3127

3228
class FileBasedCacheTest {
3329
@After
@@ -37,114 +33,39 @@ class FileBasedCacheTest {
3733

3834
@Test
3935
void "should load cached values from a provided file"() {
40-
def cache = [
41-
url: [
42-
value: 'http://test',
43-
expirationTime: Long.MAX_VALUE
44-
],
45-
cost: [
46-
value: 100,
47-
expirationTime: Long.MAX_VALUE
48-
],
49-
expired: [
50-
value: 'expired',
51-
expirationTime: 100
52-
]
53-
]
54-
55-
def cacheFile = createTempCacheFile(cache)
56-
def fileBasedCache = new FileBasedCache({ -> cacheFile })
57-
assert fileBasedCache.get('url') == 'http://test'
58-
assert fileBasedCache.get('cost') == 100
59-
assert fileBasedCache.get('nonExisting') == null
60-
assert fileBasedCache.get('expired') == null
61-
}
62-
63-
@Test
64-
void "should persist values in the file as pretty print json if time passed since last put"() {
65-
def cacheFile = createTempCacheFile([:])
36+
def cacheDir = createTempCacheDir()
37+
FileUtils.writeTextContent(cacheDir.resolve('url.json'), '"http://test"')
6638

67-
Time.timeProvider = new DummyTimeProvider([0, 11_000])
68-
69-
def fileBasedCache = new FileBasedCache({ -> cacheFile })
70-
fileBasedCache.put('accessToken', 'abc', 400)
71-
72-
Assert.assertEquals(String.format('{%n' +
73-
' "accessToken" : {%n' +
74-
' "value" : "abc",%n' +
75-
' "expirationTime" : 400%n' +
76-
' }%n' +
77-
'}'), cacheFile.text)
39+
def fileBasedCache = new FileBasedCache({ -> cacheDir })
40+
fileBasedCache.get('url').should == 'http://test'
7841
}
7942

8043
@Test
81-
void "should not persist values in the file right away"() {
82-
def cacheFile = createTempCacheFile([:])
44+
void "should save cached value to a file within cache dir"() {
45+
def cacheDir = createTempCacheDir()
8346

84-
Time.timeProvider = new DummyTimeProvider([0, 100])
47+
def fileBasedCache = new FileBasedCache({ -> cacheDir })
8548

86-
def fileBasedCache = new FileBasedCache({ -> cacheFile })
87-
fileBasedCache.put('accessToken', 'abc', 400)
49+
fileBasedCache.put('number', 200)
50+
fileBasedCache.get('number').should == 200
8851

89-
Assert.assertEquals("{ }", cacheFile.text)
52+
FileUtils.fileTextContent(cacheDir.resolve('number.json')).should == '200'
9053
}
9154

9255
@Test
93-
void "should create non expiring values if no expiration time is provided"() {
94-
def cacheFile = createTempCacheFile([:])
95-
96-
Time.timeProvider = new DummyTimeProvider([0, 11_000])
97-
def fileBasedCache = new FileBasedCache({ -> cacheFile })
98-
fileBasedCache.put('accessToken', 'abc')
99-
100-
Assert.assertEquals(String.format('{%n' +
101-
' "accessToken" : {%n' +
102-
' "value" : "abc",%n' +
103-
' "expirationTime" : 9223372036854775807%n' +
104-
' }%n' +
105-
'}'), cacheFile.text)
106-
}
56+
void "should load cache values on demand without caching"() {
57+
def cacheDir = createTempCacheDir()
10758

108-
@Test
109-
void "should handle put from multiple threads"() {
110-
def cacheFile = createTempCacheFile([:])
111-
Time.timeProvider = new DummyTimeProvider(0)
112-
def fileBasedCache = new FileBasedCache({ -> cacheFile })
113-
114-
def executor = Executors.newFixedThreadPool(300)
115-
116-
def futures = []
117-
300.times {idx ->
118-
fileBasedCache.put("value" + idx, 0)
119-
120-
futures << executor.submit {
121-
150.times {
122-
fileBasedCache.put("value" + idx, fileBasedCache.get("value" + idx) + 1)
123-
}
124-
}
125-
}
126-
127-
futures*.get()
128-
assert fileBasedCache.get("value0") == 150
129-
assert fileBasedCache.get("value1") == 150
130-
assert fileBasedCache.get("value9") == 150
131-
}
132-
133-
@Test
134-
void "should handle non existing file"() {
135-
def cachePath = Paths.get('nonExisting.json')
136-
cachePath.toFile().deleteOnExit()
137-
def cache = new FileBasedCache({ -> cachePath })
59+
def fileBasedCache = new FileBasedCache({ -> cacheDir })
13860

139-
assert cache.get('key') == null
61+
fileBasedCache.put('number', 200)
62+
FileUtils.writeTextContent(cacheDir.resolve('number.json'), '300')
14063

141-
cache.put('key', 'value')
142-
assert cache.get('key') == 'value'
64+
fileBasedCache.get('number').should == 300
14365
}
14466

145-
private static Path createTempCacheFile(Map<String, Object> content) {
146-
def cachePath = Files.createTempFile('webtau.cache', '.json')
147-
cachePath.text = JsonUtils.serializePrettyPrint(content)
67+
private static Path createTempCacheDir() {
68+
def cachePath = Files.createTempDirectory('webtau-cache')
14869
cachePath.toFile().deleteOnExit()
14970

15071
return cachePath

webtau-config/src/main/java/org/testingisdocumenting/webtau/cfg/WebTauConfig.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ public class WebTauConfig implements PrettyPrintable {
7272
"By default webtau appends webtau and its version to the user-agent, this disables that part",
7373
() -> false);
7474
private final ConfigValue workingDir = declare("workingDir", "logical working dir", () -> Paths.get(""));
75-
private final ConfigValue cachePath = declare("cachePath", "user driven cache file path",
76-
() -> workingDir.getAsPath().resolve(".webtau.cache.json"));
75+
private final ConfigValue cachePath = declare("cachePath", "user driven cache base dir",
76+
() -> workingDir.getAsPath().resolve(".webtau-cache"));
7777

7878
private final ConfigValue docPath = declare("docPath", "path for captured request/responses, screenshots and other generated " +
7979
"artifacts for documentation", () -> workingDir.getAsPath().resolve(DEFAULT_DOC_ARTIFACTS_DIR_NAME));

webtau-utils/src/main/java/org/testingisdocumenting/webtau/utils/FileUtils.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
package org.testingisdocumenting.webtau.utils;
1919

2020
import java.io.IOException;
21+
import java.io.UncheckedIOException;
2122
import java.nio.charset.StandardCharsets;
2223
import java.nio.file.Files;
2324
import java.nio.file.Path;
@@ -43,7 +44,7 @@ public static void createDirs(Path path) {
4344
try {
4445
Files.createDirectories(path);
4546
} catch (IOException e) {
46-
throw new RuntimeException(e);
47+
throw new UncheckedIOException(e);
4748
}
4849
}
4950

@@ -56,7 +57,7 @@ public static void writeBinaryContent(Path path, byte[] content) {
5657
createDirsForFile(path);
5758
Files.write(path, content);
5859
} catch (IOException e) {
59-
throw new RuntimeException(e);
60+
throw new UncheckedIOException(e);
6061
}
6162
}
6263

@@ -68,7 +69,7 @@ public static String fileTextContent(Path path) {
6869
try {
6970
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
7071
} catch (IOException e) {
71-
throw new RuntimeException(e);
72+
throw new UncheckedIOException(e);
7273
}
7374
}
7475

@@ -80,7 +81,7 @@ public static byte[] fileBinaryContent(Path path) {
8081
try {
8182
return Files.readAllBytes(path);
8283
} catch (IOException e) {
83-
throw new RuntimeException(e);
84+
throw new UncheckedIOException(e);
8485
}
8586
}
8687

0 commit comments

Comments
 (0)