Skip to content

Commit 291556d

Browse files
authored
Merge pull request #5180 from volodya-lombrozo/5179-fix-ph-classes
feat(#5179): invalidate SNAPSHOT cache on every build with Janitor
2 parents 8cf5308 + 1e557e1 commit 291556d

6 files changed

Lines changed: 336 additions & 31 deletions

File tree

.github/workflows/simian.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ jobs:
2020
with:
2121
distribution: 'temurin'
2222
java-version: 17
23-
- run: wget --quiet http://public.yegor256.com/simian.jar -O /tmp/simian.jar
23+
- run: wget --quiet https://public.yegor256.com/simian.jar -O /tmp/simian.jar
2424
- run: java -jar /tmp/simian.jar -threshold=15 "-excludes=**/EOsocketTest.java" "-excludes=**/gen" "-excludes=**/it" "**/*.java"

eo-maven-plugin/src/main/java/org/eolang/maven/Cache.java

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
package org.eolang.maven;
66

77
import java.io.IOException;
8-
import java.io.InputStream;
98
import java.nio.charset.StandardCharsets;
109
import java.nio.file.Files;
1110
import java.nio.file.Path;
@@ -126,7 +125,7 @@ private String sha(final Path any) {
126125
if (Files.isDirectory(any)) {
127126
result = this.dirSha(any);
128127
} else if (Files.isRegularFile(any)) {
129-
result = Cache.fileSha(any);
128+
result = new Sha(any).toString();
130129
} else {
131130
throw new IllegalArgumentException(
132131
String.format("Path '%s' is neither a regular file nor a directory", any)
@@ -147,7 +146,7 @@ private String dirSha(final Path dir) {
147146
stream.filter(Files::isRegularFile)
148147
.filter(this.filter::test)
149148
.sorted(Comparator.comparing(Path::toString))
150-
.map(Cache::fileSha)
149+
.map(p -> new Sha(p).toString())
151150
.map(s -> s.getBytes(StandardCharsets.UTF_8))
152151
.forEach(digest::update);
153152
}
@@ -164,31 +163,4 @@ private String dirSha(final Path dir) {
164163
);
165164
}
166165
}
167-
168-
/**
169-
* Calculate SHA-256 hash of a file and return it as Base64 string.
170-
* @param file File path
171-
* @return Base64-encoded SHA-256 hash
172-
*/
173-
private static String fileSha(final Path file) {
174-
try {
175-
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
176-
try (InputStream stream = Files.newInputStream(file)) {
177-
final byte[] buffer = new byte[8192];
178-
int read = stream.read(buffer);
179-
while (read != -1) {
180-
digest.update(buffer, 0, read);
181-
read = stream.read(buffer);
182-
}
183-
}
184-
return Base64.getEncoder().encodeToString(digest.digest());
185-
} catch (final NoSuchAlgorithmException exception) {
186-
throw new IllegalStateException("SHA-256 algorithm is not available", exception);
187-
} catch (final IOException exception) {
188-
throw new IllegalStateException(
189-
String.format("Failed to read file '%s' for hashing", file),
190-
exception
191-
);
192-
}
193-
}
194166
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
package org.eolang.maven;
6+
7+
import com.jcabi.log.Logger;
8+
import java.io.IOException;
9+
import java.net.URISyntaxException;
10+
import java.nio.file.Files;
11+
import java.nio.file.Path;
12+
import java.nio.file.Paths;
13+
import java.util.Comparator;
14+
import java.util.stream.Stream;
15+
16+
/**
17+
* Invalidates the local EO cache on every SNAPSHOT build.
18+
* Released versions use version-keyed cache directories that never collide,
19+
* so no invalidation is needed for them.
20+
* @see <a href="https://github.com/objectionary/eo/issues/5179">issue #5179</a>
21+
* @since 0.62.0
22+
*/
23+
final class Janitor {
24+
25+
/**
26+
* Fingerprint file stored inside the cache root.
27+
*/
28+
private static final String FINGERPRINT = ".snapshot-fingerprint";
29+
30+
/**
31+
* Stage directories that share the versioned cache layout.
32+
*/
33+
private static final String[] STAGES = {"parsed", "transpiled", "linted", "pulled"};
34+
35+
/**
36+
* Base cache directory, e.g. ~/.eo/.
37+
*/
38+
private final Path cache;
39+
40+
/**
41+
* Current plugin version, e.g. "1.0-SNAPSHOT".
42+
*/
43+
private final String version;
44+
45+
/**
46+
* Ctor.
47+
* @param cache Base cache directory
48+
* @param version Plugin version
49+
*/
50+
Janitor(final Path cache, final String version) {
51+
this.cache = cache;
52+
this.version = version;
53+
}
54+
55+
/**
56+
* Wipes all SNAPSHOT stage caches on every build.
57+
* Does nothing for released versions.
58+
*/
59+
void clean() {
60+
if (this.version.contains("SNAPSHOT")) {
61+
try {
62+
this.invalidate();
63+
} catch (final IOException | URISyntaxException ex) {
64+
throw new IllegalStateException("Failed to clean SNAPSHOT cache", ex);
65+
}
66+
}
67+
}
68+
69+
/**
70+
* Checks the fingerprint and wipes caches if it has changed.
71+
* @throws IOException If an IO operation fails
72+
* @throws URISyntaxException If the code source location URI is malformed
73+
*/
74+
private void invalidate() throws IOException, URISyntaxException {
75+
final String current = String.valueOf(Janitor.mtime());
76+
final Path fingerprint = this.cache.resolve(Janitor.FINGERPRINT);
77+
if (!Files.exists(fingerprint)
78+
|| !Files.readString(fingerprint).equals(current)) {
79+
this.wipeCaches();
80+
new Saved(current, fingerprint).value();
81+
}
82+
}
83+
84+
/**
85+
* Deletes the versioned cache directory for each stage if it exists.
86+
* @throws IOException If deletion fails
87+
*/
88+
private void wipeCaches() throws IOException {
89+
for (final String stage : Janitor.STAGES) {
90+
final Path dir = this.cache.resolve(stage).resolve(this.version);
91+
if (Files.exists(dir)) {
92+
Logger.info(
93+
this,
94+
"Plugin code changed, invalidating SNAPSHOT cache at %[file]s",
95+
dir
96+
);
97+
Janitor.wipe(dir);
98+
}
99+
}
100+
}
101+
102+
/**
103+
* Returns the modification time of the plugin JAR or classes directory.
104+
* Used as a build fingerprint: changes on every build, so comparing it
105+
* to the stored value always detects a new build.
106+
* @return Last-modified timestamp in milliseconds
107+
* @throws URISyntaxException If the code source location URI is malformed
108+
* @throws IOException If reading the directory fails
109+
*/
110+
private static long mtime() throws URISyntaxException, IOException {
111+
final Path location = Paths.get(
112+
Janitor.class.getProtectionDomain().getCodeSource().getLocation().toURI()
113+
);
114+
final long result;
115+
if (Files.isDirectory(location)) {
116+
try (Stream<Path> stream = Files.walk(location)) {
117+
result = stream.filter(Files::isRegularFile)
118+
.mapToLong(f -> f.toFile().lastModified())
119+
.max()
120+
.orElse(0L);
121+
}
122+
} else {
123+
result = location.toFile().lastModified();
124+
}
125+
return result;
126+
}
127+
128+
/**
129+
* Recursively deletes a directory.
130+
* @param dir Directory to delete
131+
* @throws IOException If deletion fails
132+
*/
133+
private static void wipe(final Path dir) throws IOException {
134+
try (Stream<Path> stream = Files.walk(dir)) {
135+
stream.sorted(Comparator.reverseOrder()).forEach(
136+
path -> {
137+
try {
138+
Files.delete(path);
139+
} catch (final IOException ex) {
140+
throw new IllegalStateException(
141+
String.format("Failed to delete %s", path), ex
142+
);
143+
}
144+
}
145+
);
146+
}
147+
}
148+
}

eo-maven-plugin/src/main/java/org/eolang/maven/MjSafe.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ public final void execute() throws MojoFailureException {
487487
} else {
488488
try {
489489
final long start = System.nanoTime();
490+
this.invalidate();
490491
this.execWithTimeout();
491492
if (Logger.isDebugEnabled(this)) {
492493
Logger.debug(
@@ -621,6 +622,16 @@ Proxy[] proxies() {
621622
).toArray(Proxy[]::new);
622623
}
623624

625+
/**
626+
* Invalidates the SNAPSHOT cache if the plugin descriptor is available.
627+
* The descriptor is absent in some test scenarios that use minimal setup.
628+
*/
629+
private void invalidate() {
630+
if (this.plugin != null) {
631+
new Janitor(this.cache.toPath(), this.plugin.getVersion()).clean();
632+
}
633+
}
634+
624635
/**
625636
* Runs exec command with timeout if needed.
626637
* @throws ExecutionException If unexpected exception happened during execution
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
package org.eolang.maven;
6+
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.security.MessageDigest;
12+
import java.security.NoSuchAlgorithmException;
13+
import java.util.Base64;
14+
import java.util.Comparator;
15+
import java.util.stream.Stream;
16+
17+
/**
18+
* SHA-256 hash of a file or directory.
19+
* For a directory, walks all files sorted by path and hashes their combined contents.
20+
* @since 0.62.0
21+
*/
22+
final class Sha {
23+
24+
/**
25+
* File or directory to hash.
26+
*/
27+
private final Path path;
28+
29+
/**
30+
* Ctor.
31+
* @param path File or directory to hash
32+
*/
33+
Sha(final Path path) {
34+
this.path = path;
35+
}
36+
37+
@Override
38+
public String toString() {
39+
try {
40+
return Sha.hash(this.path);
41+
} catch (final IOException | NoSuchAlgorithmException ex) {
42+
throw new IllegalStateException("Failed to compute SHA-256 hash", ex);
43+
}
44+
}
45+
46+
/**
47+
* Hashes all regular files reachable from the given path, sorted by path name.
48+
* Works for both a single file and a directory.
49+
* @param path File or directory
50+
* @return Base64-encoded SHA-256 hash
51+
* @throws IOException If reading fails
52+
* @throws NoSuchAlgorithmException If SHA-256 is unavailable
53+
*/
54+
private static String hash(final Path path) throws IOException, NoSuchAlgorithmException {
55+
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
56+
try (Stream<Path> walk = Files.walk(path)) {
57+
walk.filter(Files::isRegularFile)
58+
.sorted(Comparator.comparing(Path::toString)).forEach(
59+
file -> {
60+
try (InputStream input = Files.newInputStream(file)) {
61+
final byte[] buffer = new byte[8192];
62+
int read = input.read(buffer);
63+
while (read != -1) {
64+
digest.update(buffer, 0, read);
65+
read = input.read(buffer);
66+
}
67+
} catch (final IOException ex) {
68+
throw new IllegalStateException(
69+
String.format("Failed to read '%s'", file), ex
70+
);
71+
}
72+
}
73+
);
74+
}
75+
return Base64.getEncoder().encodeToString(digest.digest());
76+
}
77+
}

0 commit comments

Comments
 (0)