· FastAPI· pagination· cursor· SQLAlchemy· Python· API· backend
FastAPI cursor-baseret paginering — skalerbar API pagination
Cursor-baseret paginering i FastAPI med SQLAlchemy: keyset pagination, opaque cursors, stable sort, Pydantic response models og frontend TanStack Query integration.
Af M-Bus Gateway
Offset-paginering bryder ned ved store datasæt og concurrency. Cursor-baseret paginering er standard for produktions-API'er med millioner af rækker.
Offset vs. cursor pagination
# OFFSET (undgå ved store tabeller):
SELECT * FROM readings ORDER BY timestamp DESC OFFSET 50000 LIMIT 50
# Problem: PostgreSQL scanner 50.050 rækker → O(N) cost
# Problem: Nye rækker tilføjet → side 2 overlapper med side 1
# CURSOR (keyset pagination):
SELECT * FROM readings
WHERE timestamp < '2025-01-15T06:00:00Z' # Cursor position
ORDER BY timestamp DESC
LIMIT 50
# PostgreSQL bruger index direkte → O(log N) cost
# Stabil ved concurrent inserts
Opaque cursor implementation
# server/src/pagination.py
import base64
import json
from datetime import datetime
from typing import TypeVar, Generic
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
next_cursor: str | None = None
has_more: bool
def encode_cursor(values: dict) -> str:
"""Konvertér sort-værdier til opaque cursor-string."""
payload = json.dumps(values, default=str)
return base64.urlsafe_b64encode(payload.encode()).decode()
def decode_cursor(cursor: str) -> dict:
"""Dekodér cursor tilbage til sort-værdier."""
try:
payload = base64.urlsafe_b64decode(cursor.encode()).decode()
return json.loads(payload)
except Exception:
raise ValueError("Ugyldig cursor")
# Eksempel cursor ved aflæsninger sorteret på (timestamp DESC, id DESC):
# cursor = {"timestamp": "2025-01-15T06:00:00Z", "id": "550e8400-..."}
FastAPI endpoint med cursor pagination
# server/src/readings/router.py
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, and_
from server.src.pagination import Page, encode_cursor, decode_cursor
from server.src.db.models import Reading
router = APIRouter()
@router.get("/readings", response_model=Page[ReadingOut])
async def list_readings(
cursor: str | None = Query(None, description="Pagination cursor"),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(require_role("landlord")),
) -> Page[ReadingOut]:
stmt = (
select(Reading)
.where(Reading.tenant_id == current_user.tenant_id)
.order_by(Reading.timestamp.desc(), Reading.id.desc())
.limit(limit + 1) # Hent én ekstra for has_more
)
if cursor:
pos = decode_cursor(cursor)
cursor_ts = datetime.fromisoformat(pos["timestamp"])
cursor_id = pos["id"]
# Keyset condition: (timestamp, id) < (cursor_ts, cursor_id)
stmt = stmt.where(
(Reading.timestamp < cursor_ts)
| (
(Reading.timestamp == cursor_ts)
& (Reading.id < cursor_id)
)
)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = None
if has_more and items:
last = items[-1]
next_cursor = encode_cursor({
"timestamp": last.timestamp.isoformat(),
"id": str(last.id),
})
return Page(
items=[ReadingOut.model_validate(r) for r in items],
next_cursor=next_cursor,
has_more=has_more,
)
Stable sort — undgå duplicate/missing rækker
# Regel: Sort key skal være unik + stabil
# FORKERT — timestamp alene er ikke unik:
.order_by(Reading.timestamp.desc())
# Problem: Samtidige reads med samme timestamp → ubestemt rækkefølge
# KORREKT — composite sort key (timestamp, id):
.order_by(Reading.timestamp.desc(), Reading.id.desc())
# UUID er altid unik → stabil cursor position
# For TimescaleDB hypertables:
# timestamp er chunk-key → index eksisterer automatisk
# Compound index (timestamp DESC, id DESC) anbefales:
CREATE INDEX readings_cursor_idx ON readings (timestamp DESC, id DESC);
Pydantic response schema
# server/src/readings/schemas.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from decimal import Decimal
import uuid
class ReadingOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
meter_installation_id: uuid.UUID
timestamp: datetime
value: Decimal
unit: str
rssi_dbm: int | None = None
battery_level_pct: int | None = None
class ReadingsPage(BaseModel):
items: list[ReadingOut]
next_cursor: str | None = None
has_more: bool
total_hint: int | None = None # Estimeret total (valgfri)
Frontend: TanStack Query infinite scroll
// ui/src/hooks/useReadings.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { api } from "../api";
export function useReadings(installationId: string) {
return useInfiniteQuery({
queryKey: ["readings", installationId],
queryFn: ({ pageParam }) =>
api.readings.list({
installation_id: installationId,
cursor: pageParam ?? undefined,
limit: 50,
}),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? null,
staleTime: 30_000,
});
}
// Komponent:
function ReadingsList({ installationId }: { installationId: string }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useReadings(installationId);
const allReadings = data?.pages.flatMap((p) => p.items) ?? [];
return (
<div>
{allReadings.map((r) => (
<ReadingRow key={r.id} reading={r} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Henter..." : "Vis flere"}
</button>
)}
</div>
);
}
Bidirektionel pagination (frem + tilbage)
# Til UI med "forrige side" funktion:
class BidirectionalPage(BaseModel, Generic[T]):
items: list[T]
next_cursor: str | None = None
prev_cursor: str | None = None
has_more: bool
has_prev: bool
@router.get("/readings", response_model=BidirectionalPage[ReadingOut])
async def list_readings(
cursor: str | None = None,
direction: Literal["forward", "backward"] = "forward",
limit: int = 50,
...
):
if direction == "backward":
# Reverser sort og keyset condition:
stmt = stmt.order_by(Reading.timestamp.asc(), Reading.id.asc())
# ... keyset condition med > i stedet for <
items = list(reversed(items)) # Vend tilbage til korrekt rækkefølge
...
Konklusion
Cursor pagination med composite sort key (timestamp + id) er standard for IoT tidsserie-data. Opaque base64-encodede cursors skjuler implementeringsdetaljer og er sikre mod manipulation. TanStack useInfiniteQuery integrerer direkte med cursor-API'et. TimescaleDB's hypertable-indekser gør keyset queries effektive selv ved millioner af aflæsninger.