diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..69ae0c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.2] - 2026-10-28 + +`@FollowerRead` can now target a specific staleness, and rejects invalid values +instead of silently reading current data. + +### Added +- `hoursAgo`, `minutesAgo` and `secondsAgo` attributes on `@FollowerRead`. They + add up and compose to `AS OF SYSTEM TIME '-Ns'`. With nothing set, the read + still uses `follower_read_timestamp()`. + +### Changed +- `CrdbDispatcher` resolves the staleness expression through a new + `followerReadStaleness(...)` helper. When any of `hoursAgo`/`minutesAgo`/ + `secondsAgo` is set, the composed interval takes precedence over the default + `staleness` string; the existing `staleness` attribute is retained and behaves + exactly as before when no parts are set. + +### Fixed +- A negative `hoursAgo`/`minutesAgo`/`secondsAgo` now throws + `IllegalStateException` when the annotation is processed, rather than producing + an invalid clause. + +### Known limitation +- The core `@FollowerRead` only emits `AS OF SYSTEM TIME` when the annotated + method runs within a transaction (`TransactionTemplate`). Opening a dedicated + read-only transaction for a bare `@FollowerRead` is handled by the Spring Boot + starter, not the core library. + +[0.1.2]: https://github.com/cockroachlabs/cockroachdb-java-library/releases/tag/v0.1.2 diff --git a/README.md b/README.md index 657d6fc..448695e 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,62 @@ long balance = Crdb.tx(ds, c -> { | `@Transactional(retry, isolation)` | Run in a JDBC transaction (SERIALIZABLE by default); retry on serialization conflicts using `cockroach_restart` semantics | | `@CrdbRetry(maxRetries, baseDelayMillis, maxDelayMillis)` | Retry on serialization anomalies with adaptive backoff | | `@CircuitBreaker(failureThreshold, resetTimeoutMillis)` | Fast-fail after repeated failures | -| `@FollowerRead(staleness)` | Read from the nearest replica with `AS OF SYSTEM TIME` | +| `@FollowerRead(hoursAgo, minutesAgo, secondsAgo, staleness)` | Read from the nearest replica with `AS OF SYSTEM TIME` (see below) | | `@Region(value)` | Pin execution context to a region | | `@VirtualCluster(value)` / `@Workload(Priority)` | Cockroach vCluster tagging: `SET LOCAL application_name` + workload QoS on the transaction | | `@AgentTool(value, destructive)` | Expose a method as an agent-callable tool (see the `agent` module) | +### Follower reads + +CockroachDB's [follower reads](https://www.cockroachlabs.com/docs/stable/follower-reads.html) +let any replica serve reads at a slightly stale timestamp (about 4.2s old), +avoiding the round-trip to the leaseholder. `@FollowerRead` binds the staleness +into `CrdbContext`; the transaction then starts with +`SET TRANSACTION AS OF SYSTEM TIME`, so every statement reads the same snapshot +from the past. A plain `@FollowerRead` always does a follower read — it never +silently falls back to a current read. + +```java +public class CatalogServiceImpl implements CatalogService { + private final DataSource ds; + public CatalogServiceImpl(DataSource ds) { this.ds = ds; } + + @FollowerRead // follower_read_timestamp() + public List browseCatalog() { + return Crdb.tx(ds, c -> /* ... read products ... */); + } +} + +CatalogService svc = Crdb.enhance((CatalogService) new CatalogServiceImpl(ds)); +``` + +**Choosing how far back to read.** By default the read runs at +`follower_read_timestamp()`. Use `hoursAgo`, `minutesAgo` and `secondsAgo` to +read further back — they add up and compose to `AS OF SYSTEM TIME '-Ns'`: + +```java +@FollowerRead(secondsAgo = 20) // AS OF SYSTEM TIME '-20s' +@FollowerRead(minutesAgo = 5) // '-300s' +@FollowerRead(hoursAgo = 1, minutesAgo = 30) // 1h30m ago → '-5400s' +``` + +If any of `hoursAgo`/`minutesAgo`/`secondsAgo` is non-zero they take precedence +over `staleness`; when all three are zero, the raw `staleness` expression is +used (defaulting to `follower_read_timestamp()`). The interval is built only +from the validated numbers, never from caller text. + +**Fails fast instead of reading current data.** A negative `hoursAgo`, +`minutesAgo` or `secondsAgo` throws `IllegalStateException` before the method +runs, naming the offending annotation — never a silent current read. + +> **Limitation (v1):** the core library emits `AS OF SYSTEM TIME` only when the +> method actually runs inside a managed transaction — `Crdb.tx(...)`, +> `Crdb.template(ds)`, or a `@Transactional` method. A bare `@FollowerRead` +> method that opens no transaction binds the staleness but has nothing to apply +> it to, so no follower read happens on the wire. Unlike the Spring starter, +> which opens its own read-only `REQUIRES_NEW` transaction, the core does not +> auto-open one. Pair `@FollowerRead` with a transaction. + ## Cockroach Continuum ### vCluster — workload tagging diff --git a/core/src/main/java/com/cockroachlabs/crdb/annotations/FollowerRead.java b/core/src/main/java/com/cockroachlabs/crdb/annotations/FollowerRead.java index 66a2f1a..d0157b7 100644 --- a/core/src/main/java/com/cockroachlabs/crdb/annotations/FollowerRead.java +++ b/core/src/main/java/com/cockroachlabs/crdb/annotations/FollowerRead.java @@ -11,7 +11,16 @@ * Marks the annotated method as a follower-read call. The proxy engine sets the * follower-read staleness in {@link com.cockroachlabs.crdb.context.CrdbContext}, * so reads run with {@code AS OF SYSTEM TIME} against the nearest replica for - * low-latency, slightly-stale results. + * low-latency, slightly-stale results. A plain {@code @FollowerRead} always does + * a follower read; there is no fallback to a current read. + * + *

With nothing set (the default), it reads at {@code follower_read_timestamp()}, + * the most recent time any replica can serve (about 4.2s ago). Otherwise the + * {@code hoursAgo}/{@code minutesAgo}/{@code secondsAgo} parts add up, so + * {@code @FollowerRead(hoursAgo = 1, minutesAgo = 30)} reads as of 1h30m ago + * ({@code AS OF SYSTEM TIME '-5400s'}). If any of those parts is set, they take + * precedence over {@link #staleness()}. A negative part fails fast when the + * annotated method is called. */ @Documented @Retention(RUNTIME) @@ -19,8 +28,19 @@ public @interface FollowerRead { /** - * Staleness expression. Defaults to CockroachDB's exact-staleness helper + * Raw staleness expression, inlined after {@code AS OF SYSTEM TIME}. Used + * only when {@code hoursAgo}/{@code minutesAgo}/{@code secondsAgo} are all 0. + * Defaults to CockroachDB's exact-staleness helper * {@code follower_read_timestamp()}. */ String staleness() default "follower_read_timestamp()"; + + /** Hours of staleness. Added to {@code minutesAgo} and {@code secondsAgo}. */ + long hoursAgo() default 0; + + /** Minutes of staleness. Added to {@code hoursAgo} and {@code secondsAgo}. */ + long minutesAgo() default 0; + + /** Seconds of staleness. Added to {@code hoursAgo} and {@code minutesAgo}. */ + long secondsAgo() default 0; } diff --git a/core/src/main/java/com/cockroachlabs/crdb/proxy/CrdbDispatcher.java b/core/src/main/java/com/cockroachlabs/crdb/proxy/CrdbDispatcher.java index 8eb6c6f..2aa50dd 100644 --- a/core/src/main/java/com/cockroachlabs/crdb/proxy/CrdbDispatcher.java +++ b/core/src/main/java/com/cockroachlabs/crdb/proxy/CrdbDispatcher.java @@ -104,8 +104,37 @@ private static CrdbContextValues contextOf(Method method) { values = values.withWorkload(workload.value()); } if (follower != null) { - values = values.withFollowerReadStaleness(follower.staleness()); + values = values.withFollowerReadStaleness(followerReadStaleness(follower)); } return values; } + + /** + * Resolve the follower-read staleness value to inline after + * {@code AS OF SYSTEM TIME}. If any of {@code hoursAgo}/{@code minutesAgo}/ + * {@code secondsAgo} is set, the parts add up and compose to {@code '-Ns'}, + * taking precedence over {@link FollowerRead#staleness()}; otherwise the raw + * {@code staleness} expression is used. Fails fast on a negative part, so a + * follower read never silently degrades to a current read. + */ + static String followerReadStaleness(FollowerRead follower) { + long hoursAgo = follower.hoursAgo(); + long minutesAgo = follower.minutesAgo(); + long secondsAgo = follower.secondsAgo(); + if (hoursAgo < 0 || minutesAgo < 0 || secondsAgo < 0) { + throw new IllegalStateException("@FollowerRead(hoursAgo = " + hoursAgo + + ", minutesAgo = " + minutesAgo + ", secondsAgo = " + secondsAgo + + "): hoursAgo, minutesAgo and secondsAgo must not be negative"); + } + if (hoursAgo == 0 && minutesAgo == 0 && secondsAgo == 0) { + return follower.staleness(); + } + long totalSecondsAgo = Math.addExact(Math.addExact( + Math.multiplyExact(hoursAgo, 3600), + Math.multiplyExact(minutesAgo, 60)), + secondsAgo); + // AS OF SYSTEM TIME can't take a placeholder; the value is inlined, built + // only from the validated numbers above — never from user text. + return "'-" + totalSecondsAgo + "s'"; + } } diff --git a/core/src/test/java/com/cockroachlabs/crdb/proxy/FollowerReadStalenessTest.java b/core/src/test/java/com/cockroachlabs/crdb/proxy/FollowerReadStalenessTest.java new file mode 100644 index 0000000..a29e182 --- /dev/null +++ b/core/src/test/java/com/cockroachlabs/crdb/proxy/FollowerReadStalenessTest.java @@ -0,0 +1,152 @@ +package com.cockroachlabs.crdb.proxy; + +import com.cockroachlabs.crdb.Crdb; +import com.cockroachlabs.crdb.annotations.FollowerRead; +import com.cockroachlabs.crdb.context.CrdbContext; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Follower-read staleness composition and fail-fast validation. No database. + * + *

Covers both the pure {@link CrdbDispatcher#followerReadStaleness} resolver + * and its wiring through the proxy into {@link CrdbContext}. + */ +class FollowerReadStalenessTest { + + // ---- annotated specimens; annotations can't be constructed directly ---- + + @FollowerRead + void marker() { + } + + @FollowerRead(hoursAgo = 1, minutesAgo = 30) + void oneHourThirty() { + } + + @FollowerRead(secondsAgo = 90) + void ninetySeconds() { + } + + @FollowerRead(hoursAgo = 1, minutesAgo = 1, secondsAgo = 1) + void allThreeParts() { + } + + @FollowerRead(staleness = "'-1m'") + void rawExpression() { + } + + @FollowerRead(staleness = "'-1m'", secondsAgo = 5) + void partsBeatRawStaleness() { + } + + @FollowerRead(minutesAgo = -1) + void negativePart() { + } + + private static FollowerRead annotationOn(String method) throws NoSuchMethodException { + Method m = FollowerReadStalenessTest.class.getDeclaredMethod(method); + return m.getAnnotation(FollowerRead.class); + } + + // ---- resolver: composition + precedence -------------------------------- + + @Test + void defaultMarkerUsesFollowerReadTimestamp() throws Exception { + assertEquals("follower_read_timestamp()", + CrdbDispatcher.followerReadStaleness(annotationOn("marker"))); + } + + @Test + void partsAddUp() throws Exception { + assertEquals("'-5400s'", + CrdbDispatcher.followerReadStaleness(annotationOn("oneHourThirty"))); + } + + @Test + void singlePartComposes() throws Exception { + assertEquals("'-90s'", + CrdbDispatcher.followerReadStaleness(annotationOn("ninetySeconds"))); + } + + @Test + void allThreePartsAddUp() throws Exception { + assertEquals("'-3661s'", + CrdbDispatcher.followerReadStaleness(annotationOn("allThreeParts"))); + } + + @Test + void rawStalenessUsedWhenNoPartsSet() throws Exception { + assertEquals("'-1m'", + CrdbDispatcher.followerReadStaleness(annotationOn("rawExpression"))); + } + + @Test + void partsTakePrecedenceOverRawStaleness() throws Exception { + assertEquals("'-5s'", + CrdbDispatcher.followerReadStaleness(annotationOn("partsBeatRawStaleness"))); + } + + // ---- resolver: fail fast ---------------------------------------------- + + @Test + void negativePartThrows() throws Exception { + FollowerRead ann = annotationOn("negativePart"); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> CrdbDispatcher.followerReadStaleness(ann)); + // Message names the offending annotation so the failure is actionable. + org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("@FollowerRead")); + } + + // ---- end-to-end through the proxy into CrdbContext --------------------- + + interface Reader { + String staleness(); + + String composed(); + + String badWindow(); + } + + static final class ReaderImpl implements Reader { + @Override + @FollowerRead + public String staleness() { + return CrdbContext.followerReadStaleness().orElse("none"); + } + + @Override + @FollowerRead(hoursAgo = 1, minutesAgo = 30) + public String composed() { + return CrdbContext.followerReadStaleness().orElse("none"); + } + + @Override + @FollowerRead(secondsAgo = -5) + public String badWindow() { + return CrdbContext.followerReadStaleness().orElse("none"); + } + } + + @Test + void proxyBindsDefaultFollowerRead() { + Reader r = Crdb.enhance((Reader) new ReaderImpl()); + assertEquals("follower_read_timestamp()", r.staleness()); + } + + @Test + void proxyBindsComposedFollowerRead() { + Reader r = Crdb.enhance((Reader) new ReaderImpl()); + assertEquals("'-5400s'", r.composed()); + } + + @Test + void proxyFailsFastOnNegativeWindow() { + Reader r = Crdb.enhance((Reader) new ReaderImpl()); + assertThrows(IllegalStateException.class, r::badWindow); + } +}