Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Product> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,36 @@
* 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.
*
* <p>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)
@Target(METHOD)
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'";
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
Loading