diff --git a/.gitignore b/.gitignore index ae0de92..1ee5078 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ target/ +# VS Code +.vscode/ + # misc .DS_Store diff --git a/src/main/java/io/apitally/common/ApitallyClient.java b/src/main/java/io/apitally/common/ApitallyClient.java index 3a281db..ddc1737 100644 --- a/src/main/java/io/apitally/common/ApitallyClient.java +++ b/src/main/java/io/apitally/common/ApitallyClient.java @@ -11,7 +11,6 @@ import java.util.Optional; import java.util.Queue; import java.util.Random; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executors; @@ -60,7 +59,7 @@ public enum HubRequestStatus { private final String clientId; private final String env; - private final UUID instanceUuid; + private final InstanceLock instanceLock; private final HttpClient httpClient; private ScheduledExecutorService scheduler; private ScheduledFuture syncTask; @@ -80,7 +79,7 @@ public enum HubRequestStatus { public ApitallyClient(String clientId, String env, RequestLoggingConfig requestLoggingConfig) { this.clientId = clientId; this.env = env; - this.instanceUuid = java.util.UUID.randomUUID(); + this.instanceLock = InstanceLock.create(clientId, env); this.httpClient = createHttpClient(); this.requestCounter = new RequestCounter(); @@ -115,7 +114,7 @@ private URI getHubUrl(String endpoint, String query) { } public void setStartupData(List paths, Map versions, String client) { - startupData = new StartupData(instanceUuid, paths, versions, client); + startupData = new StartupData(instanceLock.getInstanceUuid(), paths, versions, client); } private void sendStartupData() { @@ -142,7 +141,7 @@ private void sendStartupData() { private void sendSyncData() { SyncData data = new SyncData( - instanceUuid, + instanceLock.getInstanceUuid(), requestCounter.getAndResetRequests(), validationErrorCounter.getAndResetValidationErrors(), serverErrorCounter.getAndResetServerErrors(), @@ -314,6 +313,7 @@ public void shutdown() { scheduler.shutdownNow(); } } + instanceLock.close(); } catch (InterruptedException e) { if (scheduler != null) { scheduler.shutdownNow(); diff --git a/src/main/java/io/apitally/common/InstanceLock.java b/src/main/java/io/apitally/common/InstanceLock.java new file mode 100644 index 0000000..b0d4b94 --- /dev/null +++ b/src/main/java/io/apitally/common/InstanceLock.java @@ -0,0 +1,135 @@ +package io.apitally.common; + +import java.io.Closeable; +import java.io.IOException; +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.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.UUID; + +public class InstanceLock implements Closeable { + private static final int MAX_SLOTS = 100; + private static final int MAX_LOCK_AGE_SECONDS = 24 * 60 * 60; + + private final UUID instanceUuid; + private final FileChannel lockChannel; + + private InstanceLock(UUID uuid, FileChannel lockChannel) { + this.instanceUuid = uuid; + this.lockChannel = lockChannel; + } + + public UUID getInstanceUuid() { + return instanceUuid; + } + + public static InstanceLock create(String clientId, String env) { + return create(clientId, env, Path.of(System.getProperty("java.io.tmpdir"), "apitally")); + } + + static InstanceLock create(String clientId, String env, Path lockDir) { + try { + Files.createDirectories(lockDir); + } catch (Exception e) { + return new InstanceLock(UUID.randomUUID(), null); + } + + String appEnvHash; + try { + appEnvHash = getAppEnvHash(clientId, env); + } catch (Exception e) { + return new InstanceLock(UUID.randomUUID(), null); + } + + for (int slot = 0; slot < MAX_SLOTS; slot++) { + Path lockPath = lockDir.resolve("instance_" + appEnvHash + "_" + slot + ".lock"); + FileChannel channel = null; + try { + channel = FileChannel.open( + lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE); + + FileLock lock = channel.tryLock(); + if (lock == null) { + channel.close(); + continue; + } + + FileTime lastModified = Files.getLastModifiedTime(lockPath); + boolean tooOld = Duration.between(lastModified.toInstant(), Instant.now()).getSeconds() > MAX_LOCK_AGE_SECONDS; + + String existingUuid = readChannel(channel); + UUID uuid = parseUuid(existingUuid); + + if (uuid != null && !tooOld) { + return new InstanceLock(uuid, channel); + } + + UUID newUuid = UUID.randomUUID(); + channel.truncate(0); + channel.write(ByteBuffer.wrap(newUuid.toString().getBytes(StandardCharsets.UTF_8))); + channel.force(true); + + return new InstanceLock(newUuid, channel); + } catch (Exception e) { + if (channel != null) { + try { + channel.close(); + } catch (IOException ignored) { + } + } + } + } + + return new InstanceLock(UUID.randomUUID(), null); + } + + private static String readChannel(FileChannel channel) throws IOException { + channel.position(0); + ByteBuffer buffer = ByteBuffer.allocate(64); + int bytesRead = channel.read(buffer); + if (bytesRead <= 0) { + return ""; + } + return new String(buffer.array(), 0, bytesRead, StandardCharsets.UTF_8).trim(); + } + + private static UUID parseUuid(String s) { + if (s == null || s.isEmpty()) { + return null; + } + try { + return UUID.fromString(s); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String getAppEnvHash(String clientId, String env) throws NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((clientId + ":" + env).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash, 0, 4); + } + + @Override + public void close() { + if (lockChannel != null) { + try { + lockChannel.close(); + } catch (IOException ignored) { + } + } + } +} diff --git a/src/test/java/io/apitally/common/InstanceLockTest.java b/src/test/java/io/apitally/common/InstanceLockTest.java new file mode 100644 index 0000000..a947759 --- /dev/null +++ b/src/test/java/io/apitally/common/InstanceLockTest.java @@ -0,0 +1,152 @@ +package io.apitally.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class InstanceLockTest { + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + tempDir = Files.createTempDirectory("apitally_test_"); + } + + @AfterEach + void tearDown() throws IOException { + if (tempDir != null && Files.exists(tempDir)) { + try (var paths = Files.walk(tempDir)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + }); + } + } + } + + @Test + void createsNewUUID() throws IOException { + String clientId = UUID.randomUUID().toString(); + String env = "test"; + + try (InstanceLock lock = InstanceLock.create(clientId, env, tempDir)) { + assertNotNull(lock.getInstanceUuid()); + + String hash = getAppEnvHash(clientId, env); + Path lockFile = tempDir.resolve("instance_" + hash + "_0.lock"); + assertTrue(Files.exists(lockFile)); + } + } + + @Test + void reusesExistingUUID() throws IOException { + String clientId = UUID.randomUUID().toString(); + String env = "test"; + + UUID firstUuid; + try (InstanceLock lock1 = InstanceLock.create(clientId, env, tempDir)) { + firstUuid = lock1.getInstanceUuid(); + } + + try (InstanceLock lock2 = InstanceLock.create(clientId, env, tempDir)) { + assertEquals(firstUuid, lock2.getInstanceUuid()); + } + } + + @Test + void differentEnvsGetDifferentUUIDs() throws IOException { + String clientId = UUID.randomUUID().toString(); + + try (InstanceLock lock1 = InstanceLock.create(clientId, "env1", tempDir); + InstanceLock lock2 = InstanceLock.create(clientId, "env2", tempDir)) { + assertNotEquals(lock1.getInstanceUuid(), lock2.getInstanceUuid()); + } + } + + @Test + void multipleSlots() throws IOException { + String clientId = UUID.randomUUID().toString(); + String env = "test"; + + try (InstanceLock lock1 = InstanceLock.create(clientId, env, tempDir); + InstanceLock lock2 = InstanceLock.create(clientId, env, tempDir); + InstanceLock lock3 = InstanceLock.create(clientId, env, tempDir)) { + + assertNotEquals(lock1.getInstanceUuid(), lock2.getInstanceUuid()); + assertNotEquals(lock2.getInstanceUuid(), lock3.getInstanceUuid()); + assertNotEquals(lock1.getInstanceUuid(), lock3.getInstanceUuid()); + + String hash = getAppEnvHash(clientId, env); + assertTrue(Files.exists(tempDir.resolve("instance_" + hash + "_0.lock"))); + assertTrue(Files.exists(tempDir.resolve("instance_" + hash + "_1.lock"))); + assertTrue(Files.exists(tempDir.resolve("instance_" + hash + "_2.lock"))); + } + } + + @Test + void overwritesOldUUID() throws IOException { + String clientId = UUID.randomUUID().toString(); + String env = "test"; + String hash = getAppEnvHash(clientId, env); + + String oldUuid = "550e8400-e29b-41d4-a716-446655440000"; + Path lockFile = tempDir.resolve("instance_" + hash + "_0.lock"); + Files.writeString(lockFile, oldUuid); + Instant oldTime = Instant.now().minus(25, ChronoUnit.HOURS); + Files.setLastModifiedTime(lockFile, java.nio.file.attribute.FileTime.from(oldTime)); + + try (InstanceLock lock = InstanceLock.create(clientId, env, tempDir)) { + assertNotEquals(UUID.fromString(oldUuid), lock.getInstanceUuid()); + assertNotNull(lock.getInstanceUuid()); + } + } + + @Test + void overwritesInvalidUUID() throws IOException { + String clientId = UUID.randomUUID().toString(); + String env = "test"; + String hash = getAppEnvHash(clientId, env); + + Path lockFile = tempDir.resolve("instance_" + hash + "_0.lock"); + Files.writeString(lockFile, "not-a-valid-uuid"); + + UUID uuid; + try (InstanceLock lock = InstanceLock.create(clientId, env, tempDir)) { + uuid = lock.getInstanceUuid(); + assertNotNull(uuid); + } + + String content = Files.readString(lockFile).trim(); + assertEquals(uuid.toString(), content); + } + + private static String getAppEnvHash(String clientId, String env) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((clientId + ":" + env).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash, 0, 4); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } +}