285 lines
10 KiB
Go
285 lines
10 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
const (
|
|
channelUpdateRetentionCandidateBatch = 256
|
|
// Keep one channel row/checkpoint hot-lock window short even when the maintenance pass has a
|
|
// large global budget. The outer seek loop may consume many chunks; this is a transaction cap,
|
|
// not a per-pass correctness cap.
|
|
channelUpdateRetentionTransactionBatch = 256
|
|
)
|
|
|
|
// PruneChannelUpdateEvents atomically removes a bounded contiguous prefix of one channel's durable
|
|
// event log. The retained floor advances only through complete event rows actually deleted; a target
|
|
// inside a pts_count interval leaves that row and the floor untouched.
|
|
func (s *ChannelStore) PruneChannelUpdateEvents(ctx context.Context, channelID int64, throughPts, limit int) (domain.ChannelUpdateRetentionResult, error) {
|
|
return s.pruneChannelUpdateEvents(ctx, channelID, throughPts, 0, limit)
|
|
}
|
|
|
|
// DeleteExpiredChannelUpdateEvents performs a bounded global retention pass without OFFSET. The
|
|
// candidate seek uses (date,channel_id,pts), selects only the oldest retained row of each channel,
|
|
// then delegates deletion/floor advancement to the per-channel transactional primitive.
|
|
func (s *ChannelStore) DeleteExpiredChannelUpdateEvents(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
|
|
if olderThan <= 0 {
|
|
return 0, nil
|
|
}
|
|
limit = normalizeChannelUpdateRetentionLimit(limit)
|
|
cutoff := int(time.Now().Add(-olderThan).Unix())
|
|
deleted := 0
|
|
excluded := make([]int64, 0)
|
|
var isolatedErrors []error
|
|
for deleted < limit {
|
|
candidateLimit := limit - deleted
|
|
if candidateLimit > channelUpdateRetentionCandidateBatch {
|
|
candidateLimit = channelUpdateRetentionCandidateBatch
|
|
}
|
|
channelIDs, err := s.expiredChannelUpdateCandidates(ctx, cutoff, candidateLimit, excluded)
|
|
if err != nil {
|
|
isolatedErrors = append(isolatedErrors, err)
|
|
return deleted, errors.Join(isolatedErrors...)
|
|
}
|
|
if len(channelIDs) == 0 {
|
|
break
|
|
}
|
|
for _, channelID := range channelIDs {
|
|
if deleted >= limit {
|
|
break
|
|
}
|
|
chunkLimit := limit - deleted
|
|
if chunkLimit > channelUpdateRetentionTransactionBatch {
|
|
chunkLimit = channelUpdateRetentionTransactionBatch
|
|
}
|
|
result, err := s.pruneChannelUpdateEvents(ctx, channelID, math.MaxInt32, cutoff, chunkLimit)
|
|
if err != nil {
|
|
// A durable-log gap/invalid row is an invariant violation for this channel, but it must
|
|
// not starve every healthy channel behind the oldest candidate. Isolate it for this pass,
|
|
// keep its floor unchanged (the tx rolled back), continue globally, then report all errors.
|
|
excluded = append(excluded, channelID)
|
|
isolatedErrors = append(isolatedErrors, fmt.Errorf("channel %d retention isolated: %w", channelID, err))
|
|
continue
|
|
}
|
|
if result.Deleted == 0 {
|
|
// Another retention worker may have consumed this head after the seek.
|
|
// Exclude it for this pass so one raced channel cannot spin forever.
|
|
excluded = append(excluded, channelID)
|
|
continue
|
|
}
|
|
deleted += result.Deleted
|
|
}
|
|
}
|
|
return deleted, errors.Join(isolatedErrors...)
|
|
}
|
|
|
|
// expiredChannelUpdateCandidates keeps each SQL seek bounded, while the caller loops through as
|
|
// many seeks as needed to consume the requested deletion budget. The 256 value is a fetch/page
|
|
// size, not a per-maintenance-pass correctness cap.
|
|
func (s *ChannelStore) expiredChannelUpdateCandidates(ctx context.Context, cutoff, limit int, excluded []int64) ([]int64, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT e.channel_id
|
|
FROM channel_update_events e
|
|
LEFT JOIN channel_update_checkpoints cp ON cp.channel_id = e.channel_id
|
|
WHERE e.date < $1
|
|
AND e.pts > COALESCE(cp.retained_through_pts, 0)
|
|
AND NOT (e.channel_id = ANY($3::bigint[]))
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM channel_update_events earlier
|
|
WHERE earlier.channel_id = e.channel_id
|
|
AND earlier.pts > COALESCE(cp.retained_through_pts, 0)
|
|
AND earlier.pts < e.pts
|
|
)
|
|
ORDER BY e.date ASC, e.channel_id ASC, e.pts ASC
|
|
LIMIT $2`, cutoff, limit, excluded)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list expired channel update candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
channelIDs := make([]int64, 0, limit)
|
|
for rows.Next() {
|
|
var channelID int64
|
|
if err := rows.Scan(&channelID); err != nil {
|
|
return nil, fmt.Errorf("scan expired channel update candidate: %w", err)
|
|
}
|
|
channelIDs = append(channelIDs, channelID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate expired channel update candidates: %w", err)
|
|
}
|
|
return channelIDs, nil
|
|
}
|
|
|
|
func (s *ChannelStore) pruneChannelUpdateEvents(ctx context.Context, channelID int64, throughPts, beforeDate, limit int) (domain.ChannelUpdateRetentionResult, error) {
|
|
if channelID == 0 || throughPts < 0 {
|
|
return domain.ChannelUpdateRetentionResult{}, domain.ErrChannelInvalid
|
|
}
|
|
limit = normalizeChannelUpdateRetentionLimit(limit)
|
|
if limit > channelUpdateRetentionTransactionBatch {
|
|
limit = channelUpdateRetentionTransactionBatch
|
|
}
|
|
var result domain.ChannelUpdateRetentionResult
|
|
err := withTx(ctx, s.db, "prune channel update events", func(tx pgx.Tx) error {
|
|
checkpoint, err := lockChannelUpdateCheckpoint(ctx, tx, channelID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if throughPts > checkpoint.LatestPts {
|
|
throughPts = checkpoint.LatestPts
|
|
}
|
|
if throughPts <= checkpoint.RetainedThroughPts {
|
|
result.Checkpoint = checkpoint
|
|
return nil
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT pts, pts_count, date
|
|
FROM channel_update_events
|
|
WHERE channel_id = $1
|
|
AND pts > $2
|
|
AND pts <= $3
|
|
ORDER BY pts ASC
|
|
LIMIT $4
|
|
FOR UPDATE`, channelID, checkpoint.RetainedThroughPts, throughPts, limit)
|
|
if err != nil {
|
|
return fmt.Errorf("list channel update prune prefix: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
cursor := checkpoint.RetainedThroughPts
|
|
ptsToDelete := make([]int32, 0, limit)
|
|
for rows.Next() {
|
|
var pts, ptsCount, date int
|
|
if err := rows.Scan(&pts, &ptsCount, &date); err != nil {
|
|
return fmt.Errorf("scan channel update prune prefix: %w", err)
|
|
}
|
|
if beforeDate > 0 && date >= beforeDate {
|
|
break
|
|
}
|
|
if ptsCount <= 0 {
|
|
return fmt.Errorf("prune channel update events: channel %d has invalid pts_count=%d at pts=%d", channelID, ptsCount, pts)
|
|
}
|
|
if pts != cursor+ptsCount {
|
|
return fmt.Errorf(
|
|
"prune channel update events: channel %d has gap after pts %d: event pts=%d pts_count=%d",
|
|
channelID, cursor, pts, ptsCount,
|
|
)
|
|
}
|
|
cursor = pts
|
|
ptsToDelete = append(ptsToDelete, int32(pts))
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate channel update prune prefix: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
if len(ptsToDelete) == 0 {
|
|
result.Checkpoint = checkpoint
|
|
return nil
|
|
}
|
|
tag, err := tx.Exec(ctx, `
|
|
DELETE FROM channel_update_events
|
|
WHERE channel_id = $1
|
|
AND pts = ANY($2::int[])`, channelID, ptsToDelete)
|
|
if err != nil {
|
|
return fmt.Errorf("delete channel update prune prefix: %w", err)
|
|
}
|
|
if got := int(tag.RowsAffected()); got != len(ptsToDelete) {
|
|
return fmt.Errorf("delete channel update prune prefix: deleted %d rows, expected %d", got, len(ptsToDelete))
|
|
}
|
|
tag, err = tx.Exec(ctx, `
|
|
UPDATE channel_update_checkpoints
|
|
SET retained_through_pts = $2,
|
|
latest_event_date = GREATEST(latest_event_date, $3),
|
|
latest_pts = GREATEST(latest_pts, $4),
|
|
updated_at = now()
|
|
WHERE channel_id = $1`, channelID, cursor, checkpoint.LatestEventDate, checkpoint.LatestPts)
|
|
if err != nil {
|
|
return fmt.Errorf("advance channel update retained floor: %w", err)
|
|
}
|
|
if tag.RowsAffected() != 1 {
|
|
return fmt.Errorf("advance channel update retained floor: checkpoint row disappeared for channel %d", channelID)
|
|
}
|
|
checkpoint.RetainedThroughPts = cursor
|
|
result = domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint, Deleted: len(ptsToDelete)}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.ChannelUpdateRetentionResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// lockChannelUpdateCheckpoint follows the channel writer lock order: channels row first, checkpoint
|
|
// second. Event insertion updates channels.pts before upserting the checkpoint, so retention cannot
|
|
// race a committed pts without its durable event/checkpoint.
|
|
func lockChannelUpdateCheckpoint(ctx context.Context, tx pgx.Tx, channelID int64) (domain.ChannelUpdateRetentionCheckpoint, error) {
|
|
var lockedChannelID int64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id
|
|
FROM channels
|
|
WHERE id = $1
|
|
FOR UPDATE`, channelID).Scan(&lockedChannelID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ChannelUpdateRetentionCheckpoint{}, domain.ErrChannelInvalid
|
|
}
|
|
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf("lock channel for update retention: %w", err)
|
|
}
|
|
checkpoint := domain.ChannelUpdateRetentionCheckpoint{ChannelID: channelID}
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT retained_through_pts, latest_event_date, latest_pts
|
|
FROM channel_update_checkpoints
|
|
WHERE channel_id = $1
|
|
FOR UPDATE`, channelID).Scan(
|
|
&checkpoint.RetainedThroughPts,
|
|
&checkpoint.LatestEventDate,
|
|
&checkpoint.LatestPts,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf(
|
|
"lock channel update checkpoint: invariant violation: channel %d has no retention checkpoint",
|
|
channelID,
|
|
)
|
|
}
|
|
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf("lock channel update checkpoint: %w", err)
|
|
}
|
|
return checkpoint, nil
|
|
}
|
|
|
|
func normalizeChannelUpdateRetentionLimit(limit int) int {
|
|
if limit <= 0 || limit > domain.MaxChannelUpdateRetentionBatch {
|
|
return domain.MaxChannelUpdateRetentionBatch
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func getChannelUpdateCheckpoint(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.ChannelUpdateRetentionCheckpoint, error) {
|
|
checkpoint := domain.ChannelUpdateRetentionCheckpoint{ChannelID: channelID}
|
|
err := db.QueryRow(ctx, `
|
|
SELECT retained_through_pts, latest_event_date, latest_pts
|
|
FROM channel_update_checkpoints
|
|
WHERE channel_id = $1`, channelID).Scan(
|
|
&checkpoint.RetainedThroughPts,
|
|
&checkpoint.LatestEventDate,
|
|
&checkpoint.LatestPts,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf(
|
|
"get channel update checkpoint: invariant violation: channel %d has no retention checkpoint",
|
|
channelID,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf("get channel update checkpoint: %w", err)
|
|
}
|
|
return checkpoint, nil
|
|
}
|