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). Versions are per
package; this entry covers the `cockroachdb`, `cockroachdb-core`, and
`cockroachdb-sqlalchemy` packages.

## [Unreleased]

Follower reads can now target a specific staleness, and a `@follower_read` /
SQLAlchemy session helper that raised `AttributeError` at runtime has been fixed.

### Added
- `hours_ago`, `minutes_ago` and `seconds_ago` keyword-only parameters on the
`follower_read()` decorator, `FollowerReadContext.scoped()`, and the SQLAlchemy
session `follower_read()` helper. They add up and compose to
`AS OF SYSTEM TIME '-Ns'`; with nothing set the read still uses
`follower_read_timestamp()`.
- `cockroachdb.staleness.build_staleness()` — the single source of truth for
composing + validating a staleness expression. Exported from `cockroachdb`.

### Fixed
- **SQLAlchemy `session.follower_read()` raised `AttributeError` at runtime.** It
called `FollowerReadContext.set(...)` / `reset(...)` (methods that do not exist)
and passed a `bool` into a `str | None` contextvar. It now uses the real
`FollowerReadContext.scoped()` contextmanager.
- A negative `hours_ago`/`minutes_ago`/`seconds_ago` now raises `ValueError` at
call time instead of producing an invalid clause.

### Known limitation
- `MetricsCollector.record_follower_read()` is still not wired to any execution
path (no call sites library-wide). Recording follower-read metrics requires
instrumenting the SQLAlchemy event / psycopg execution layer — tracked
separately.
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,72 @@ with CockroachDBSession(engine) as session:

### 4. 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. Mark a read with `@follower_read` and it runs `AS OF SYSTEM TIME` — always a follower read, never a silent fallback to current data:

```python
from cockroachdb import follower_read


@follower_read(staleness="follower_read_timestamp()")
@follower_read()
def get_dashboard_data(conn):
"""Read from the nearest follower replica — no cross-region latency."""
with conn.cursor() as cur:
cur.execute("SELECT * FROM analytics_summary")
return cur.fetchall()
```

#### Choosing how far back to read

By default the read runs at `follower_read_timestamp()`. Use the keyword-only `hours_ago`, `minutes_ago` and `seconds_ago` params to read further back — they add up and compose to `AS OF SYSTEM TIME '-Ns'`:

```python
@follower_read() # follower_read_timestamp()
@follower_read(seconds_ago=20) # '-20s'
@follower_read(minutes_ago=5) # '-300s'
@follower_read(hours_ago=1) # '-3600s'
@follower_read(hours_ago=1, minutes_ago=30) # '-5400s' (1h30m ago)
```

When no additive part is set, the `staleness` string is used — pass a built-in like `follower_read_timestamp()` (default) or a raw interval. A time more recent than `follower_read_timestamp()` still reads historical data, but the leaseholder serves it rather than the nearest replica; a time older than the table's GC window fails in CockroachDB.

#### Context manager and SQLAlchemy

The same additive params work on the `FollowerReadContext.scoped()` context manager (and the template/SQLAlchemy-session helpers that delegate to it):

```python
from cockroachdb.core.follower_read import FollowerReadContext

with FollowerReadContext.scoped(minutes_ago=2, seconds_ago=30): # '-150s'
... # every read in this block runs AS OF SYSTEM TIME

# SQLAlchemy session helper — reads go to a follower for the callback's duration
session.follower_read(lambda s: s.query(Product).all(), seconds_ago=20)
```

The SQLAlchemy session `follower_read()` helper routes through the same mechanism and is fully functional (an earlier version raised `AttributeError` at runtime; fixed).

#### Misuse fails instead of reading current data

Staleness composition lives in one place — `build_staleness()` (exported from `cockroachdb`) — which never silently falls back to a current read. A **negative** `hours_ago`/`minutes_ago`/`seconds_ago` raises `ValueError` at call time (at decoration time for `@follower_read`, before any session opens for the context manager and SQLAlchemy helper):

```python
from cockroachdb import build_staleness

build_staleness(minutes_ago=2, seconds_ago=30) # "'-150s'"
build_staleness() # "follower_read_timestamp()"
build_staleness(seconds_ago=-1) # raises ValueError
```

#### When to use follower reads

| Use case | Follower read? |
|----------|---------------|
| Product browsing, search results | Yes — slight staleness is imperceptible |
| Dashboards, aggregate metrics | Yes — a few seconds lag is fine |
| Checkout, payment | No — must see real-time inventory |
| Authentication, permissions | No — must see latest credentials |


### 5. Multi-Region with Context Manager

```python
Expand Down
31 changes: 27 additions & 4 deletions packages/cockroachdb-core/src/cockroachdb/core/follower_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from contextlib import contextmanager
from typing import TYPE_CHECKING

from cockroachdb.staleness import build_staleness

if TYPE_CHECKING:
from collections.abc import Iterator

Expand All @@ -31,10 +33,31 @@ def current_staleness(cls) -> str | None:

@classmethod
@contextmanager
def scoped(cls, staleness: str = "follower_read_timestamp()") -> Iterator[str]:
"""Context manager that activates follower reads for the block duration."""
token = _follower_read_var.set(staleness)
def scoped(
cls,
staleness: str = "follower_read_timestamp()",
*,
hours_ago: int = 0,
minutes_ago: int = 0,
seconds_ago: int = 0,
) -> Iterator[str]:
"""Context manager that activates follower reads for the block duration.

Staleness is additive: ``hours_ago``/``minutes_ago``/``seconds_ago`` add
up and, when positive, compose to ``'-Ns'`` taking precedence over
*staleness*. With no parts set, *staleness* is used.

Raises:
ValueError: if any additive part is negative (fail fast).
"""
resolved = build_staleness(
staleness,
hours_ago=hours_ago,
minutes_ago=minutes_ago,
seconds_ago=seconds_ago,
)
token = _follower_read_var.set(resolved)
try:
yield staleness
yield resolved
finally:
_follower_read_var.reset(token)
40 changes: 34 additions & 6 deletions packages/cockroachdb-core/src/cockroachdb/core/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,24 @@ def in_region(self, region: str) -> RegionalTemplate:

@contextmanager
def follower_read(
self, staleness: str = "follower_read_timestamp()"
self,
staleness: str = "follower_read_timestamp()",
*,
hours_ago: int = 0,
minutes_ago: int = 0,
seconds_ago: int = 0,
) -> Iterator[CockroachDBTemplate]:
"""Context manager that activates follower reads."""
with FollowerReadContext.scoped(staleness):
"""Context manager that activates follower reads.

Staleness is additive; see :meth:`FollowerReadContext.scoped`. A
negative additive part raises ``ValueError``.
"""
with FollowerReadContext.scoped(
staleness,
hours_ago=hours_ago,
minutes_ago=minutes_ago,
seconds_ago=seconds_ago,
):
yield self


Expand Down Expand Up @@ -249,8 +263,22 @@ def in_region(self, region: str) -> AsyncRegionalTemplate:

@contextmanager
def follower_read(
self, staleness: str = "follower_read_timestamp()"
self,
staleness: str = "follower_read_timestamp()",
*,
hours_ago: int = 0,
minutes_ago: int = 0,
seconds_ago: int = 0,
) -> Iterator[AsyncCockroachDBTemplate]:
"""Context manager that activates follower reads."""
with FollowerReadContext.scoped(staleness):
"""Context manager that activates follower reads.

Staleness is additive; see :meth:`FollowerReadContext.scoped`. A
negative additive part raises ``ValueError``.
"""
with FollowerReadContext.scoped(
staleness,
hours_ago=hours_ago,
minutes_ago=minutes_ago,
seconds_ago=seconds_ago,
):
yield self
42 changes: 32 additions & 10 deletions packages/cockroachdb-sqlalchemy/src/cockroachdb/sqla/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,30 +165,52 @@ def execute_with_retry(self, fn: Callable[[Session], T]) -> T:
# Follower reads
# ------------------------------------------------------------------

def follower_read(self, fn: Callable[[Session], T]) -> T:
def follower_read(
self,
fn: Callable[[Session], T],
*,
staleness: str = "follower_read_timestamp()",
hours_ago: int = 0,
minutes_ago: int = 0,
seconds_ago: int = 0,
) -> T:
"""Execute *fn* as a CockroachDB follower read.

Sets the :data:`FollowerReadContext` contextvar so the event
listener injects ``SET TRANSACTION AS OF SYSTEM TIME
follower_read_timestamp()`` before the query.
Activates the :class:`FollowerReadContext` for the block so the
event listener injects ``SET TRANSACTION AS OF SYSTEM TIME
{staleness}`` before the query.

Staleness is additive: ``hours_ago``/``minutes_ago``/``seconds_ago``
add up and, when positive, compose to ``'-Ns'`` taking precedence
over *staleness*. With no parts set, *staleness* is used.

Args:
fn: A callable that receives a ``Session`` and returns a
result. Should perform **read-only** operations.
staleness: AS OF SYSTEM TIME expression used when no additive
parts are set.
hours_ago: Whole hours of staleness to add. Must be >= 0.
minutes_ago: Whole minutes of staleness to add. Must be >= 0.
seconds_ago: Whole seconds of staleness to add. Must be >= 0.

Returns:
The return value of *fn*.

Raises:
ValueError: if any additive part is negative (fail fast — raised
before any session is opened).
"""
token = FollowerReadContext.set(True)
try:
with FollowerReadContext.scoped(
staleness,
hours_ago=hours_ago,
minutes_ago=minutes_ago,
seconds_ago=seconds_ago,
):
session = self._session_factory()
try:
result = fn(session)
return result
return fn(session)
finally:
session.close()
finally:
FollowerReadContext.reset(token)

# ------------------------------------------------------------------
# Region-scoped operations
Expand Down
2 changes: 2 additions & 0 deletions packages/cockroachdb/src/cockroachdb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
CockroachDBRetryExhausted,
RegionValidationError,
)
from cockroachdb.staleness import build_staleness
from cockroachdb.types import (
DistanceMetric,
IsolationLevel,
Expand All @@ -48,6 +49,7 @@
"TransactionOutcome",
# Meta
"__version__",
"build_staleness",
"changefeed_handler",
"crdb_region",
# Decorators
Expand Down
30 changes: 26 additions & 4 deletions packages/cockroachdb/src/cockroachdb/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import TYPE_CHECKING, ParamSpec, TypeVar

from cockroachdb.exceptions import CockroachDBRetryExhausted, is_retryable_error
from cockroachdb.staleness import build_staleness
from cockroachdb.types import IsolationLevel

if TYPE_CHECKING:
Expand Down Expand Up @@ -103,18 +104,39 @@ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:

def follower_read(
staleness: str = "follower_read_timestamp()",
*,
hours_ago: int = 0,
minutes_ago: int = 0,
seconds_ago: int = 0,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Execute read queries AS OF SYSTEM TIME for low-latency replica reads.

The staleness parameter controls freshness — either a built-in function
like follower_read_timestamp() or an interval like '-5s'.
Always performs a follower read — there is no fallback to a current read.

Staleness is additive: ``hours_ago``/``minutes_ago``/``seconds_ago`` add up
and, when their total is positive, compose to ``'-Ns'`` which takes
precedence over *staleness*. With no parts set, *staleness* is used (a
built-in like ``follower_read_timestamp()`` or an interval like ``'-5s'``).

Args:
staleness: AS OF SYSTEM TIME expression.
staleness: AS OF SYSTEM TIME expression used when no additive parts set.
hours_ago: Whole hours of staleness to add. Must be >= 0.
minutes_ago: Whole minutes of staleness to add. Must be >= 0.
seconds_ago: Whole seconds of staleness to add. Must be >= 0.

Raises:
ValueError: if any additive part is negative (raised at decoration
time — fail fast rather than silently read current data).
"""
resolved = build_staleness(
staleness,
hours_ago=hours_ago,
minutes_ago=minutes_ago,
seconds_ago=seconds_ago,
)

def decorator(fn: Callable[P, T]) -> Callable[P, T]:
fn.__crdb_follower_read__ = staleness # type: ignore[attr-defined]
fn.__crdb_follower_read__ = resolved # type: ignore[attr-defined]
return fn

return decorator
Expand Down
52 changes: 52 additions & 0 deletions packages/cockroachdb/src/cockroachdb/staleness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Shared helper for building ``AS OF SYSTEM TIME`` staleness expressions.

Lives in the base ``cockroachdb`` package (not ``cockroachdb-core``) because
both the ``@follower_read`` decorator here and ``FollowerReadContext.scoped``
in core must reuse it, and core depends on base — not the other way around.
"""

from __future__ import annotations


def build_staleness(
staleness: str = "follower_read_timestamp()",
*,
hours_ago: int = 0,
minutes_ago: int = 0,
seconds_ago: int = 0,
) -> str:
"""Build an ``AS OF SYSTEM TIME`` expression.

Additive staleness: ``hours_ago``/``minutes_ago``/``seconds_ago`` add up
and, when their total is positive, compose to ``'-Ns'`` which takes
precedence over *staleness*. When no parts are set, *staleness* is returned
unchanged (default ``follower_read_timestamp()``).

This is the single source of truth for the fail-fast rule: a negative part
raises immediately rather than silently falling back to a current read.

Args:
staleness: Fallback AS OF SYSTEM TIME expression used when no
additive parts are set.
hours_ago: Whole hours of staleness to add. Must be >= 0.
minutes_ago: Whole minutes of staleness to add. Must be >= 0.
seconds_ago: Whole seconds of staleness to add. Must be >= 0.

Returns:
Either ``'-Ns'`` (composed from the additive parts) or *staleness*.

Raises:
ValueError: if any additive part is negative.
"""
for name, value in (
("hours_ago", hours_ago),
("minutes_ago", minutes_ago),
("seconds_ago", seconds_ago),
):
if value < 0:
raise ValueError(f"{name} must be >= 0, got {value}")

total_seconds = hours_ago * 3600 + minutes_ago * 60 + seconds_ago
if total_seconds > 0:
return f"'-{total_seconds}s'"
return staleness
Loading
Loading