fix: feed repitition
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// feedSeenTTL bounds a home-feed "session": after this idle window the
|
||||
// per-user seen-set expires and the feed starts fresh from the top.
|
||||
const feedSeenTTL = 30 * time.Minute
|
||||
|
||||
// FeedRepo tracks, per user, which post ids have already been served in the
|
||||
// current home-feed session (a Redis SET) so paginated requests never repeat
|
||||
// a post — and so the viewer-state-dependent feed ordering can't cause an
|
||||
// OFFSET to drift over already-served rows.
|
||||
//
|
||||
// Nil-safe: with no Redis configured every method degrades to a no-op /
|
||||
// empty, and the feed falls back to plain offset paging.
|
||||
type FeedRepo struct {
|
||||
redis *Redis
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewFeedRepo(redis *Redis, logger *zap.Logger) *FeedRepo {
|
||||
return &FeedRepo{redis: redis, logger: logger}
|
||||
}
|
||||
|
||||
// Enabled reports whether the Redis-backed seen-set is available.
|
||||
func (r *FeedRepo) Enabled() bool { return r != nil && r.redis != nil }
|
||||
|
||||
func (r *FeedRepo) key(userID string) string { return "feed:seen:" + userID }
|
||||
|
||||
// Seen returns the post ids already served to the user this session (empty
|
||||
// when Redis is unavailable or the session is fresh).
|
||||
func (r *FeedRepo) Seen(ctx context.Context, userID string) []string {
|
||||
if !r.Enabled() {
|
||||
return nil
|
||||
}
|
||||
ids, err := r.redis.Client.SMembers(ctx, r.key(userID)).Result()
|
||||
if err != nil {
|
||||
// Degrade rather than fail the feed — worst case is a possible repeat.
|
||||
r.logger.Warn("feed seen: read failed", zap.String("user", userID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// MarkSeen records the ids just served and refreshes the session TTL.
|
||||
func (r *FeedRepo) MarkSeen(ctx context.Context, userID string, ids []string) {
|
||||
if !r.Enabled() || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
members := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
members[i] = id
|
||||
}
|
||||
pipe := r.redis.Client.Pipeline()
|
||||
pipe.SAdd(ctx, r.key(userID), members...)
|
||||
pipe.Expire(ctx, r.key(userID), feedSeenTTL)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
r.logger.Warn("feed seen: write failed", zap.String("user", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears the user's seen-set — called when a fresh feed session begins
|
||||
// (the first page request) so reopening the feed serves from the top again.
|
||||
func (r *FeedRepo) Reset(ctx context.Context, userID string) {
|
||||
if !r.Enabled() {
|
||||
return
|
||||
}
|
||||
if err := r.redis.Client.Del(ctx, r.key(userID)).Err(); err != nil {
|
||||
r.logger.Warn("feed seen: reset failed", zap.String("user", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -219,14 +219,27 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
||||
// tier 0 — posts by people the viewer follows that they haven't liked yet
|
||||
// tier 1 — everything else (followed-and-liked, followers', strangers')
|
||||
//
|
||||
// The viewer's own posts and anything involving a block (either direction)
|
||||
// are excluded. The tier is computed in ORDER BY via correlated EXISTS, so no
|
||||
// extra columns leak into scanPost. No total is computed (infinite scroll
|
||||
// infers "more" from a full page); offset paging keeps it simple. At larger
|
||||
// scale this would move to a precomputed fan-out / keyset cursor.
|
||||
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int) ([]model.Post, error) {
|
||||
// The viewer's own posts, anything involving a block (either direction), and
|
||||
// any id in `exclude` (the already-served set) are filtered out. The tier is
|
||||
// computed in ORDER BY via correlated EXISTS, so no extra columns leak into
|
||||
// scanPost. No total is computed (infinite scroll infers "more" from a full
|
||||
// page).
|
||||
//
|
||||
// With a non-empty `exclude` the caller drives pagination purely by exclusion
|
||||
// (pass offset 0): since the tier depends on viewer state — liking a post
|
||||
// moves it between tiers — an OFFSET would drift and repeat/skip rows, so the
|
||||
// seen-set is the correct cursor. Without Redis, `exclude` is empty and OFFSET
|
||||
// paginates (the frontend de-dups the rare drift). At larger scale this moves
|
||||
// to a precomputed fan-out / keyset cursor.
|
||||
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int, exclude []string) ([]model.Post, error) {
|
||||
// nil would encode as SQL NULL, and `id <> ALL(NULL)` is NULL (excludes
|
||||
// every row); a non-nil empty array means "exclude nothing".
|
||||
if exclude == nil {
|
||||
exclude = []string{}
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id <> $1
|
||||
AND p.id <> ALL($4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
|
||||
@@ -247,7 +260,7 @@ func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, of
|
||||
END) ASC,
|
||||
p.created_at DESC, p.id DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset)
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset, exclude)
|
||||
return posts, err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user