Files
upstream-centrifugo/internal/pgstreambroker/history.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

191 lines
6.5 KiB
Go

package pgstreambroker
import (
"context"
"errors"
"fmt"
"math"
"time"
"github.com/centrifugal/centrifuge"
"github.com/jackc/pgx/v5"
)
// History reads publications from a channel's history table, applying the
// per-channel TTL filter at read time. Unlike the Redis broker, the PG stream
// broker does NOT refresh meta TTL on read — meta TTL is refreshed on Publish
// (via the 3-tier MetaTTL fallback). This means channels with active readers
// but no publishers will eventually have their meta expire. This matches the
// map broker's ReadState/ReadStream behavior, and allows History to use read
// replicas for scaling.
//
// If the channel has no meta row (never published, or meta expired), History
// returns an empty result with a zero StreamPosition — matching the map
// broker's behavior on ErrNoRows.
func (e *PostgresStreamBroker) History(ch string, opts centrifuge.HistoryOptions) ([]*centrifuge.Publication, centrifuge.StreamPosition, error) {
ctx := context.Background()
pool := e.getReadPool(ch, true) // can use replicas — this is a pure read
// Read meta — pure SELECT, no UPSERT.
metaQuery := fmt.Sprintf(
`SELECT top_offset, epoch, history_ttl, history_size FROM %s WHERE channel = $1`,
e.names.meta,
)
var topOffset int64
var epoch string
var historyTTL *time.Duration
var historySize *int
err := pool.QueryRow(ctx, metaQuery, ch).Scan(&topOffset, &epoch, &historyTTL, &historySize)
if errors.Is(err, pgx.ErrNoRows) {
// Channel never published or meta expired — return empty, matching
// map broker behavior. The centrifuge node treats empty epoch as
// "don't check epoch" (epochOK := sinceEpoch == "" || sinceEpoch == streamTop.Epoch).
return nil, centrifuge.StreamPosition{}, nil
}
if err != nil {
return nil, centrifuge.StreamPosition{}, fmt.Errorf("postgres stream broker: history meta: %w", err)
}
streamPos := centrifuge.StreamPosition{
Offset: uint64(topOffset),
Epoch: epoch,
}
// Filter.Limit == 0 → caller only wants the position.
if opts.Filter.Limit == 0 {
return nil, streamPos, nil
}
// Compute effective bounds Go-side.
windowStart := int64(1)
if historySize != nil && *historySize > 0 {
if topOffset >= int64(*historySize) {
windowStart = topOffset - int64(*historySize) + 1
}
}
sinceOffset := int64(0)
if opts.Filter.Since != nil {
sinceOffset = int64(opts.Filter.Since.Offset)
}
// Compute the offset window [effectiveStart, effectiveEnd]. Forward returns
// publications after Since (ascending); reverse returns publications before
// Since (descending) - the boundary sits on the opposite side of the cursor,
// as in the memory/redis/pgmapbroker brokers. Using Since+1 for both
// directions made reverse pagination return already-seen or empty pages and
// never reach older messages.
effectiveStart := windowStart
effectiveEnd := topOffset
if opts.Filter.Reverse {
// Reverse returns publications strictly before Since (descending).
// Guard sinceOffset == 0: centrifuge's Since.Offset is uint64, so its
// `since.Offset - 1` underflows to MaxUint64 for offset 0, which means
// "read the whole stream from the top". We keep effectiveEnd = topOffset
// in that case instead of computing -1 (which would match no rows).
if opts.Filter.Since != nil && sinceOffset > 0 {
effectiveEnd = sinceOffset - 1
}
} else {
effectiveStart = sinceOffset + 1
if windowStart > effectiveStart {
effectiveStart = windowStart
}
}
effectiveLimit := math.MaxInt32
if opts.Filter.Limit > 0 && opts.Filter.Limit < effectiveLimit {
effectiveLimit = opts.Filter.Limit
}
if historySize != nil && *historySize > 0 && *historySize < effectiveLimit {
effectiveLimit = *historySize
}
// Read-time TTL filter cutoff.
var cutoff time.Time
if historyTTL != nil && *historyTTL > 0 {
cutoff = time.Now().Add(-*historyTTL)
} else {
cutoff = time.Unix(0, 0) // sentinel — no filter
}
order := "ASC"
if opts.Filter.Reverse {
order = "DESC"
}
// The `epoch = $5` predicate rejects rows from prior epochs that linger
// between meta TTL expiry and the next partition retention drop. The
// time-based `created_at > cutoff` filter usually catches them too, but
// fails when history_ttl is increased between the dead epoch's last
// publish and the read — the new wider cutoff lets old rows through.
// The new (channel, epoch, channel_offset) WHERE kind=0 partial index
// resolves the predicate without scanning dead-epoch rows.
rowsQuery := fmt.Sprintf(`
SELECT channel_offset, epoch, data, tags, client_id, user_id, conn_info, chan_info, prev_data
FROM %s
WHERE channel = $1
AND kind = 0
AND channel_offset >= $2
AND channel_offset <= $3
AND created_at > $4
AND epoch = $5
ORDER BY channel_offset %s
LIMIT $6
`, e.names.stream, order)
rows, err := pool.Query(ctx, rowsQuery, ch, effectiveStart, effectiveEnd, cutoff, epoch, effectiveLimit)
if err != nil {
return nil, centrifuge.StreamPosition{}, fmt.Errorf("postgres stream broker: history query: %w", err)
}
defer rows.Close()
allocHint := effectiveLimit
if allocHint > 1024 {
allocHint = 1024
}
arena := byteArena{buf: make([]byte, 0, allocHint*64)}
backing := make([]centrifuge.Publication, 0, allocHint)
pubs := make([]*centrifuge.Publication, 0, allocHint)
var fmts pgColFormats
for rows.Next() {
if fmts == nil {
fmts = pgColFormatsFromRows(rows)
}
raw := rows.RawValues()
backing = append(backing, centrifuge.Publication{})
p := &backing[len(backing)-1]
p.Offset = pgRawUint64(raw[0], fmts[0])
p.Epoch = pgRawString(&arena, raw[1])
p.Data = e.rawDataBytes(&arena, raw[2], fmts[2])
p.Tags = pgRawJSONBMap(raw[3])
if raw[4] != nil {
p.Info = &centrifuge.ClientInfo{
ClientID: pgRawString(&arena, raw[4]),
UserID: pgRawString(&arena, raw[5]),
ConnInfo: e.rawDataBytes(&arena, raw[6], fmts[6]),
ChanInfo: e.rawDataBytes(&arena, raw[7], fmts[7]),
}
}
_ = raw[8] // prev_data — used by outbox, not by History
pubs = append(pubs, p)
}
if err := rows.Err(); err != nil {
return nil, centrifuge.StreamPosition{}, fmt.Errorf("postgres stream broker: history rows: %w", err)
}
return pubs, streamPos, nil
}
// RemoveHistory wipes publications (kind=0) for a channel via the
// __PREFIX__remove_history SQL function.
func (e *PostgresStreamBroker) RemoveHistory(ch string) error {
ctx := context.Background()
query := fmt.Sprintf(`SELECT %s($1, $2)`, e.names.removeHistory)
if _, err := e.pool.Exec(ctx, query, ch, e.conf.NumShards); err != nil {
return fmt.Errorf("postgres stream broker: remove_history: %w", err)
}
return nil
}