Stage 7 · Master
Phase 11 — Notice Service
Pagination
Replace fragile offsets with opaque seek cursors so a live notice feed stays stable while new posts are published.
Where does OFFSET pagination lie on a live feed?
User Service already retired OFFSET for the member directory, and the seek-cursor mechanics carry over unchanged. What does not carry over is the insertion pattern. A user directory grows slowly and mostly at the tail; a notice feed mutates at the *head* — scheduled notices flip to published on a timer, and admins post urgent messages that immediately sort above everything the resident has already scrolled past. That makes the drift OFFSET introduces visible within a single session rather than once a week.
| Moment | Page 1 request (LIMIT 2) | Page 2 request (LIMIT 2 OFFSET 2) | What the resident sees |
|---|---|---|---|
| 08:00:00 | N5, N4 | N3, N2 | Everything looks correct before new inserts arrive |
| 08:00:05 after urgent N6 publishes | N6, N5 | N4, N3 | Resident sees duplicate N4 and may never see N2 in this scroll session |
A newly published notice changes every later position in the ordered result set. That is why duplicates and skips happen even when the SQL query itself is perfectly valid.
What does a published-feed seek cursor look like?
A keyset cursor turns the last seen row into the boundary for the next page. For the default published feed, the natural sort key is (published_at DESC, id DESC), so the next page asks for rows strictly less than the previous page's tail. The unique id tie-breaker is essential because many notices can share the same published_at down to the second.
SELECT id, title, body, published_at
FROM notices
WHERE org_id = $1
AND state = 'published'
AND (published_at, id) < ($2, $3)
ORDER BY published_at DESC, id DESC
LIMIT $4;This query reuses the feed index from lesson 1 instead of counting past older rows the way OFFSET does.
How does search pagination switch to rank-based cursors?
When a search query is active, published_at is no longer the primary ordering dimension. The active ordering is (score DESC, id DESC), so the cursor must carry the last score and id instead. Because the score depends on the recency formula from lesson 3, the cursor also needs the reference timestamp used for ranking or page 2 can reorder borderline matches against a new 'now'.
package notice
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
type CursorMode string
const (
CursorModePublished CursorMode = "published"
CursorModeSearch CursorMode = "search"
)
var ErrInvalidCursor = errors.New("notice pagination: invalid cursor")
type FeedCursor struct {
Mode CursorMode
PublishedAt *time.Time
Score *float64
EvaluatedAt *time.Time
ID uuid.UUID
}
type cursorEnvelope struct {
Payload FeedCursor
Sig string
}
type CursorCodec struct {
secret []byte
}
func NewCursorCodec(secret string) (*CursorCodec, error) {
if secret == "" {
return nil, fmt.Errorf("%w: empty secret", ErrInvalidCursor)
}
return &CursorCodec{secret: []byte(secret)}, nil
}
func (c *CursorCodec) Encode(cursor FeedCursor) (string, error) {
payloadBytes, err := json.Marshal(cursor)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, c.secret)
mac.Write(payloadBytes)
envelope := cursorEnvelope{
Payload: cursor,
Sig: hex.EncodeToString(mac.Sum(nil)),
}
raw, err := json.Marshal(envelope)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (c *CursorCodec) Decode(token string) (FeedCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return FeedCursor{}, fmt.Errorf("%w: base64 decode failed", ErrInvalidCursor)
}
var env cursorEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return FeedCursor{}, fmt.Errorf("%w: json decode failed", ErrInvalidCursor)
}
payloadBytes, err := json.Marshal(env.Payload)
if err != nil {
return FeedCursor{}, err
}
mac := hmac.New(sha256.New, c.secret)
mac.Write(payloadBytes)
expected := mac.Sum(nil)
actual, err := hex.DecodeString(env.Sig)
if err != nil || !hmac.Equal(expected, actual) {
return FeedCursor{}, fmt.Errorf("%w: signature mismatch", ErrInvalidCursor)
}
if env.Payload.ID == uuid.Nil {
return FeedCursor{}, fmt.Errorf("%w: missing id", ErrInvalidCursor)
}
switch env.Payload.Mode {
case CursorModePublished:
if env.Payload.PublishedAt == nil {
return FeedCursor{}, fmt.Errorf("%w: published cursor missing published_at", ErrInvalidCursor)
}
case CursorModeSearch:
if env.Payload.Score == nil || env.Payload.EvaluatedAt == nil {
return FeedCursor{}, fmt.Errorf("%w: search cursor missing score or evaluated_at", ErrInvalidCursor)
}
default:
return FeedCursor{}, fmt.Errorf("%w: unsupported mode", ErrInvalidCursor)
}
return env.Payload, nil
}
func PublishedSeekPredicate(cursor FeedCursor, firstArg int) (string, []any, error) {
if cursor.Mode != CursorModePublished || cursor.PublishedAt == nil {
return "", nil, fmt.Errorf("%w: published cursor required", ErrInvalidCursor)
}
return fmt.Sprintf(" AND (published_at, id) < ($%d, $%d)", firstArg, firstArg+1), []any{cursor.PublishedAt.UTC(), cursor.ID}, nil
}
func SearchSeekPredicate(cursor FeedCursor, firstArg int) (string, []any, error) {
if cursor.Mode != CursorModeSearch || cursor.Score == nil || cursor.EvaluatedAt == nil {
return "", nil, fmt.Errorf("%w: search cursor required", ErrInvalidCursor)
}
return fmt.Sprintf(" AND (score, id) < ($%d, $%d)", firstArg, firstArg+1), []any{*cursor.Score, cursor.ID}, nil
}
func NewPublishedCursor(item Notice) FeedCursor {
return FeedCursor{
Mode: CursorModePublished,
PublishedAt: item.PublishedAt,
ID: item.ID,
}
}
func NewSearchCursor(item SearchResult, evaluatedAt time.Time) FeedCursor {
score := item.Score
stamp := evaluatedAt.UTC()
return FeedCursor{
Mode: CursorModeSearch,
Score: &score,
EvaluatedAt: &stamp,
ID: item.ID,
}
}The public token is still just one base64url string, but the HMAC means clients cannot tamper with published_at or score to skip around the feed.
How do you prove the cursor is safe and stable?
A stable test flow requests page 1, inserts a newer published notice, then requests page 2 using the page 1 cursor. With OFFSET you would see a duplicate or a skip; with keyset you continue exactly after the last row you already saw. The security check is to mutate one character in the cursor token and confirm the API rejects it with a bad-request error instead of silently trusting client-controlled ordering data.
Applied exercise
Replace page-two OFFSET with a seek cursor
At 10:00 a resident requests page 1 of the notice feed and receives N5, N4. At 10:00:03 a new urgent notice N6 is published. The same resident then asks for page 2. You need to show the exact wrong OFFSET result and the correct keyset result.
- Write the page 2 OFFSET query and identify the duplicate or skipped notice it causes after N6 is inserted.
- Write the page 2 keyset query using the page 1 tail row as the cursor boundary.
- Explain why id must be part of the cursor even when published_at already exists.
- Describe how the cursor format prevents a client from inventing an arbitrary older position.
Deliverable
A side-by-side pagination walkthrough with one incorrect OFFSET result, one correct keyset result, and a short note on cursor tamper resistance.
Completion checks
- The OFFSET example names the actual duplicate or skipped row rather than hand-waving about 'maybe inconsistent'.
- The keyset query uses both published_at and id in the ordering and predicate.
- The cursor discussion mentions opacity plus server-side signature verification.
Why does keyset pagination require a unique tie-break column such as id?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.