From b20b9a9887e34d29fa5193916d5ee5a9ab3c1d8a Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:23:30 +0000 Subject: [PATCH 1/3] SDK regeneration --- .fern/metadata.json | 4 +- build.gradle | 4 +- .../com/schematic/api/core/ClientOptions.java | 4 +- .../api/cache/RedisCacheProviderTest.java | 158 ------------------ 4 files changed, 6 insertions(+), 164 deletions(-) delete mode 100644 src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java diff --git a/.fern/metadata.json b/.fern/metadata.json index 7f8583c..cb67663 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -14,6 +14,6 @@ "implementation redis.clients:jedis:5.2.0" ] }, - "originGitCommit": "57973a46a4d4ac7fb0dba2d9296b6f6073efd859", - "sdkVersion": "1.3.0" + "originGitCommit": "65ef3b80b68cd7596daad7fa53232ea510ad01f2", + "sdkVersion": "1.4.0" } \ No newline at end of file diff --git a/build.gradle b/build.gradle index 994224f..672a8ee 100644 --- a/build.gradle +++ b/build.gradle @@ -52,7 +52,7 @@ java { group = 'com.schematichq' -version = '1.3.0' +version = '1.4.0' jar { dependsOn(":generatePomFileForMavenPublication") @@ -83,7 +83,7 @@ publishing { maven(MavenPublication) { groupId = 'com.schematichq' artifactId = 'schematic-java' - version = '1.3.0' + version = '1.4.0' from components.java pom { name = 'Schematic' diff --git a/src/main/java/com/schematic/api/core/ClientOptions.java b/src/main/java/com/schematic/api/core/ClientOptions.java index 93b175a..b5d94b9 100644 --- a/src/main/java/com/schematic/api/core/ClientOptions.java +++ b/src/main/java/com/schematic/api/core/ClientOptions.java @@ -38,10 +38,10 @@ private ClientOptions( this.headers.putAll(headers); this.headers.putAll(new HashMap() { { - put("User-Agent", "com.schematichq:schematic-java/1.3.0"); + put("User-Agent", "com.schematichq:schematic-java/1.4.0"); put("X-Fern-Language", "JAVA"); put("X-Fern-SDK-Name", "com.schematic.fern:api-sdk"); - put("X-Fern-SDK-Version", "1.3.0"); + put("X-Fern-SDK-Version", "1.4.0"); } }); this.headerSuppliers = headerSuppliers; diff --git a/src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java b/src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java deleted file mode 100644 index dd7747a..0000000 --- a/src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.schematic.api.cache; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -import java.lang.reflect.Field; -import java.nio.ByteBuffer; -import java.time.Duration; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import redis.clients.jedis.JedisPooled; -import redis.clients.jedis.params.ScanParams; -import redis.clients.jedis.resps.ScanResult; - -@ExtendWith(MockitoExtension.class) -class RedisCacheProviderTest { - - @Mock - private JedisPooled jedis; - - private Map store; - - @BeforeEach - void setUp() { - store = new HashMap<>(); - } - - @Test - void deleteMissing_withMultiSegmentPrefix_doesNotTouchSiblingCaches() { - // Regression: a user-supplied keyPrefix containing an internal colon used to cause - // deleteMissing to derive a scan pattern that matched every cache under the same - // namespace, silently wiping company/user caches whenever a flag snapshot synced. - String keyPrefix = "myapp:schematic:"; - store.put("myapp:schematic:flags:v1:flag-a", "{}"); // keep - store.put("myapp:schematic:flags:v1:flag-b", "{}"); // keep - store.put("myapp:schematic:flags:v1:flag-stale", "{}"); // must be deleted - store.put("myapp:schematic:company:v1:comp-1", "{}"); // sibling — must NOT be deleted - store.put("myapp:schematic:user:v1:user-1", "{}"); // sibling — must NOT be deleted - wirePatternAwareScan(); - - RedisCacheProvider provider = - new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), keyPrefix, String.class); - - provider.deleteMissing(Arrays.asList("flags:v1:flag-a", "flags:v1:flag-b"), "flags:*"); - - verify(jedis).del(new String[] {"myapp:schematic:flags:v1:flag-stale"}); - verify(jedis, times(1)).del(any(String[].class)); - } - - @Test - void deleteMissing_withDefaultPrefix_stillIsolatesByScanPattern() { - String keyPrefix = "schematic:"; - store.put("schematic:flags:v1:flag-a", "{}"); - store.put("schematic:flags:v1:flag-stale", "{}"); - store.put("schematic:company:v1:comp-1", "{}"); - wirePatternAwareScan(); - - RedisCacheProvider provider = - new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), keyPrefix, String.class); - - provider.deleteMissing(Arrays.asList("flags:v1:flag-a"), "flags:*"); - - verify(jedis).del(new String[] {"schematic:flags:v1:flag-stale"}); - } - - @Test - void deleteMissing_withNullScanPattern_isNoOp() { - // Without an explicit scan scope we'd have to wildcard the entire keyPrefix, - // which would match sibling caches. deleteMissing must refuse to guess. - RedisCacheProvider provider = - new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), "schematic:", String.class); - - provider.deleteMissing(Arrays.asList("flags:v1:flag-a"), null); - provider.deleteMissing(Arrays.asList("flags:v1:flag-a"), ""); - - verify(jedis, never()).scan(anyString(), any(ScanParams.class)); - verify(jedis, never()).del(any(String[].class)); - } - - @Test - void deleteMissing_withoutScanPattern_isNoOp() { - // The one-arg deleteMissing inherits CacheProvider's default (no-op) because - // Redis has no safe way to scope a wipe without a pattern. - RedisCacheProvider provider = - new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), "schematic:", String.class); - - provider.deleteMissing(Arrays.asList("flags:v1:flag-a")); - - verify(jedis, never()).scan(anyString(), any(ScanParams.class)); - verify(jedis, never()).del(any(String[].class)); - } - - @Test - void deleteMissing_withEmptyKeysList_isNoOp() { - RedisCacheProvider provider = - new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), "schematic:", String.class); - - provider.deleteMissing(null, "flags:*"); - provider.deleteMissing(java.util.Collections.emptyList(), "flags:*"); - - verify(jedis, never()).scan(anyString(), any(ScanParams.class)); - verify(jedis, never()).del(any(String[].class)); - } - - /** - * Wires up {@code jedis.scan} to filter our fake {@link #store} by the actual MATCH - * pattern on the {@link ScanParams} passed in — so a too-broad scan pattern would - * return sibling-cache keys, exactly as real Redis would. - */ - private void wirePatternAwareScan() { - when(jedis.scan(anyString(), any(ScanParams.class))).thenAnswer(invocation -> { - ScanParams params = invocation.getArgument(1); - String glob = extractMatchPattern(params); - Pattern regex = globToRegex(glob); - List matches = store.keySet().stream() - .filter(k -> regex.matcher(k).matches()) - .collect(Collectors.toList()); - return new ScanResult<>(ScanParams.SCAN_POINTER_START, matches); - }); - } - - // ScanParams doesn't expose a public getter for the MATCH pattern, so read it via - // reflection. In Jedis 5.2.0 the internal field is an EnumMap. - // Update if the internal field layout changes. - @SuppressWarnings("unchecked") - private static String extractMatchPattern(ScanParams params) throws Exception { - Field paramsField = ScanParams.class.getDeclaredField("params"); - paramsField.setAccessible(true); - Map paramsMap = (Map) paramsField.get(params); - for (Map.Entry entry : paramsMap.entrySet()) { - if (entry.getKey().toString().equalsIgnoreCase("MATCH")) { - return new String(entry.getValue().array()); - } - } - throw new AssertionError("ScanParams has no MATCH pattern set"); - } - - private static Pattern globToRegex(String glob) { - StringBuilder sb = new StringBuilder(); - for (char c : glob.toCharArray()) { - if (c == '*') sb.append(".*"); - else if ("\\.^$|?+()[]{}".indexOf(c) >= 0) sb.append('\\').append(c); - else sb.append(c); - } - return Pattern.compile(sb.toString()); - } -} From be83c8cf7161fb187972ae6ffc2e8966631c9023 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 28 Apr 2026 08:27:07 -0600 Subject: [PATCH 2/3] re add redis cache test --- .fernignore | 1 + .../api/cache/RedisCacheProviderTest.java | 158 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java diff --git a/.fernignore b/.fernignore index 497ef95..2798499 100644 --- a/.fernignore +++ b/.fernignore @@ -29,5 +29,6 @@ src/test/java/com/schematic/api/TestLogger.java src/test/java/com/schematic/api/TestOfflineMode.java src/test/java/com/schematic/api/TestReadme.java src/test/java/com/schematic/api/TestSchematic.java +src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java src/test/java/com/schematic/api/datastream/ src/test/java/com/schematic/webhook/ diff --git a/src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java b/src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java new file mode 100644 index 0000000..dd7747a --- /dev/null +++ b/src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java @@ -0,0 +1,158 @@ +package com.schematic.api.cache; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.params.ScanParams; +import redis.clients.jedis.resps.ScanResult; + +@ExtendWith(MockitoExtension.class) +class RedisCacheProviderTest { + + @Mock + private JedisPooled jedis; + + private Map store; + + @BeforeEach + void setUp() { + store = new HashMap<>(); + } + + @Test + void deleteMissing_withMultiSegmentPrefix_doesNotTouchSiblingCaches() { + // Regression: a user-supplied keyPrefix containing an internal colon used to cause + // deleteMissing to derive a scan pattern that matched every cache under the same + // namespace, silently wiping company/user caches whenever a flag snapshot synced. + String keyPrefix = "myapp:schematic:"; + store.put("myapp:schematic:flags:v1:flag-a", "{}"); // keep + store.put("myapp:schematic:flags:v1:flag-b", "{}"); // keep + store.put("myapp:schematic:flags:v1:flag-stale", "{}"); // must be deleted + store.put("myapp:schematic:company:v1:comp-1", "{}"); // sibling — must NOT be deleted + store.put("myapp:schematic:user:v1:user-1", "{}"); // sibling — must NOT be deleted + wirePatternAwareScan(); + + RedisCacheProvider provider = + new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), keyPrefix, String.class); + + provider.deleteMissing(Arrays.asList("flags:v1:flag-a", "flags:v1:flag-b"), "flags:*"); + + verify(jedis).del(new String[] {"myapp:schematic:flags:v1:flag-stale"}); + verify(jedis, times(1)).del(any(String[].class)); + } + + @Test + void deleteMissing_withDefaultPrefix_stillIsolatesByScanPattern() { + String keyPrefix = "schematic:"; + store.put("schematic:flags:v1:flag-a", "{}"); + store.put("schematic:flags:v1:flag-stale", "{}"); + store.put("schematic:company:v1:comp-1", "{}"); + wirePatternAwareScan(); + + RedisCacheProvider provider = + new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), keyPrefix, String.class); + + provider.deleteMissing(Arrays.asList("flags:v1:flag-a"), "flags:*"); + + verify(jedis).del(new String[] {"schematic:flags:v1:flag-stale"}); + } + + @Test + void deleteMissing_withNullScanPattern_isNoOp() { + // Without an explicit scan scope we'd have to wildcard the entire keyPrefix, + // which would match sibling caches. deleteMissing must refuse to guess. + RedisCacheProvider provider = + new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), "schematic:", String.class); + + provider.deleteMissing(Arrays.asList("flags:v1:flag-a"), null); + provider.deleteMissing(Arrays.asList("flags:v1:flag-a"), ""); + + verify(jedis, never()).scan(anyString(), any(ScanParams.class)); + verify(jedis, never()).del(any(String[].class)); + } + + @Test + void deleteMissing_withoutScanPattern_isNoOp() { + // The one-arg deleteMissing inherits CacheProvider's default (no-op) because + // Redis has no safe way to scope a wipe without a pattern. + RedisCacheProvider provider = + new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), "schematic:", String.class); + + provider.deleteMissing(Arrays.asList("flags:v1:flag-a")); + + verify(jedis, never()).scan(anyString(), any(ScanParams.class)); + verify(jedis, never()).del(any(String[].class)); + } + + @Test + void deleteMissing_withEmptyKeysList_isNoOp() { + RedisCacheProvider provider = + new RedisCacheProvider<>(jedis, Duration.ofSeconds(60), "schematic:", String.class); + + provider.deleteMissing(null, "flags:*"); + provider.deleteMissing(java.util.Collections.emptyList(), "flags:*"); + + verify(jedis, never()).scan(anyString(), any(ScanParams.class)); + verify(jedis, never()).del(any(String[].class)); + } + + /** + * Wires up {@code jedis.scan} to filter our fake {@link #store} by the actual MATCH + * pattern on the {@link ScanParams} passed in — so a too-broad scan pattern would + * return sibling-cache keys, exactly as real Redis would. + */ + private void wirePatternAwareScan() { + when(jedis.scan(anyString(), any(ScanParams.class))).thenAnswer(invocation -> { + ScanParams params = invocation.getArgument(1); + String glob = extractMatchPattern(params); + Pattern regex = globToRegex(glob); + List matches = store.keySet().stream() + .filter(k -> regex.matcher(k).matches()) + .collect(Collectors.toList()); + return new ScanResult<>(ScanParams.SCAN_POINTER_START, matches); + }); + } + + // ScanParams doesn't expose a public getter for the MATCH pattern, so read it via + // reflection. In Jedis 5.2.0 the internal field is an EnumMap. + // Update if the internal field layout changes. + @SuppressWarnings("unchecked") + private static String extractMatchPattern(ScanParams params) throws Exception { + Field paramsField = ScanParams.class.getDeclaredField("params"); + paramsField.setAccessible(true); + Map paramsMap = (Map) paramsField.get(params); + for (Map.Entry entry : paramsMap.entrySet()) { + if (entry.getKey().toString().equalsIgnoreCase("MATCH")) { + return new String(entry.getValue().array()); + } + } + throw new AssertionError("ScanParams has no MATCH pattern set"); + } + + private static Pattern globToRegex(String glob) { + StringBuilder sb = new StringBuilder(); + for (char c : glob.toCharArray()) { + if (c == '*') sb.append(".*"); + else if ("\\.^$|?+()[]{}".indexOf(c) >= 0) sb.append('\\').append(c); + else sb.append(c); + } + return Pattern.compile(sb.toString()); + } +} From 9f5c5df4c9d8f4bd83fc6ebc0d783a18d1ce7c8e Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 28 Apr 2026 09:29:13 -0600 Subject: [PATCH 3/3] add configurable event buffer delay --- .../java/com/schematic/api/EventBuffer.java | 23 ++++++++++++++++- .../com/schematic/api/TestEventBuffer.java | 25 +++++++++---------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/schematic/api/EventBuffer.java b/src/main/java/com/schematic/api/EventBuffer.java index 8e95c76..e7cd959 100644 --- a/src/main/java/com/schematic/api/EventBuffer.java +++ b/src/main/java/com/schematic/api/EventBuffer.java @@ -23,6 +23,7 @@ public class EventBuffer implements AutoCloseable { private final ConcurrentLinkedQueue events; private final int maxBatchSize; private final Duration flushInterval; + private final Duration retryInitialDelay; private final HttpEventSender eventSender; private final SchematicLogger logger; private final ScheduledExecutorService scheduler; @@ -40,9 +41,29 @@ public class EventBuffer implements AutoCloseable { * @param flushInterval How often to automatically flush the buffer */ public EventBuffer(HttpEventSender eventSender, SchematicLogger logger, int maxBatchSize, Duration flushInterval) { + this(eventSender, logger, maxBatchSize, flushInterval, RETRY_INITIAL_DELAY); + } + + /** + * Creates a new EventBuffer instance with a custom initial retry delay. + * + * @param eventSender The HTTP sender used to send events to the capture service + * @param logger Logger instance for error reporting and monitoring + * @param maxBatchSize Maximum number of events to include in a single batch + * @param flushInterval How often to automatically flush the buffer + * @param retryInitialDelay Base delay for the first retry attempt; subsequent retries use + * exponential backoff (delay * 2^attempt) with ±25% jitter + */ + public EventBuffer( + HttpEventSender eventSender, + SchematicLogger logger, + int maxBatchSize, + Duration flushInterval, + Duration retryInitialDelay) { this.events = new ConcurrentLinkedQueue<>(); this.maxBatchSize = maxBatchSize > 0 ? maxBatchSize : DEFAULT_MAX_BATCH_SIZE; this.flushInterval = flushInterval != null ? flushInterval : DEFAULT_FLUSH_INTERVAL; + this.retryInitialDelay = retryInitialDelay != null ? retryInitialDelay : RETRY_INITIAL_DELAY; this.eventSender = eventSender; this.logger = logger; this.droppedEvents = new AtomicInteger(0); @@ -119,7 +140,7 @@ private void sendBatchWithRetry(List batch, int retryCou } catch (Exception e) { if (retryCount < MAX_RETRY_ATTEMPTS) { - long baseDelay = RETRY_INITIAL_DELAY.toMillis() * (1L << retryCount); + long baseDelay = retryInitialDelay.toMillis() * (1L << retryCount); // Add ±25% jitter double jitter = (Math.random() - 0.5) * 0.5 * baseDelay; long delayMillis = Math.max(0, baseDelay + (long) jitter); diff --git a/src/test/java/com/schematic/api/TestEventBuffer.java b/src/test/java/com/schematic/api/TestEventBuffer.java index 978330b..ddec7bc 100644 --- a/src/test/java/com/schematic/api/TestEventBuffer.java +++ b/src/test/java/com/schematic/api/TestEventBuffer.java @@ -115,22 +115,21 @@ void pushAfterStop_ShouldLogError() throws IOException { @Test void flushWithError_ShouldLogError() throws IOException { - doThrow(new IOException("Test error")).when(eventSender).sendBatch(any()); - CreateEventRequestBody event = mock(CreateEventRequestBody.class); - eventBuffer.push(event); + // Use a tiny retry delay so the three async retries finish in milliseconds rather + // than the production ~7s, keeping the test fast and deterministic. + EventBuffer fastRetryBuffer = + new EventBuffer(eventSender, logger, 5, Duration.ofMillis(100), Duration.ofMillis(1)); + try { + doThrow(new IOException("Test error")).when(eventSender).sendBatch(any()); + CreateEventRequestBody event = mock(CreateEventRequestBody.class); + fastRetryBuffer.push(event); - eventBuffer.flush(); + fastRetryBuffer.flush(); - try { - // Wait for all retries to complete - // Initial delay + 2nd retry + 3rd retry = 1000ms + 2000ms + 4000ms = 7000ms - Thread.sleep(8000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // Restore the interrupt flag - fail("Test was interrupted while waiting for retries"); + verify(logger, timeout(2_000)).error(contains("Failed to flush events")); + } finally { + fastRetryBuffer.close(); } - - verify(logger).error(contains("Failed to flush events")); } @Test