Support per-user similarity computation and handle resolutionUnverified
f988072 parent: 450d849 modified
internal/cluster/cron.go +14 -1 | @@ -23,7 +23,20 @@ func (c *Cron) Run(ctx context.Context) error { | ||
| 23 | 23 | c.logger.Info("starting similarity computation") |
| 24 | 24 | start := time.Now() |
| 25 | 25 | |
| 26 | - c.engine.ComputeAll(ctx) | |
| 26 | + if !c.engine.mu.TryLock() { | |
| 27 | + c.logger.Info("skipping computation: already in progress") | |
| 28 | + } else { | |
| 29 | + if err := c.engine.ComputeFeedSimilarity(ctx); err != nil { | |
| 30 | + c.engine.logger.Error("feed similarity failed", "error", err) | |
| 31 | + } | |
| 32 | + if err := c.engine.ComputeUserSimilarity(ctx); err != nil { | |
| 33 | + c.engine.logger.Error("user similarity failed", "error", err) | |
| 34 | + } | |
| 35 | + if err := c.engine.ComputeRecommendations(ctx); err != nil { | |
| 36 | + c.engine.logger.Error("recommendations failed", "error", err) | |
| 37 | + } | |
| 38 | + c.engine.mu.Unlock() | |
| 39 | + } | |
| 27 | 40 | |
| 28 | 41 | metrics.ClusterRuns.Inc() |
| 29 | 42 | metrics.ClusterDuration.Observe(time.Since(start).Seconds()) |
| @@ -23,7 +23,20 @@ func (c *Cron) Run(ctx context.Context) error { | |||
| 23 | c.logger.Info("starting similarity computation") | 23 | c.logger.Info("starting similarity computation") |
| 24 | start := time.Now() | 24 | start := time.Now() |
| 25 | 25 | ||
| 26 | - c.engine.ComputeAll(ctx) | 26 | + if !c.engine.mu.TryLock() { |
| 27 | + c.logger.Info("skipping computation: already in progress") | ||
| 28 | + } else { | ||
| 29 | + if err := c.engine.ComputeFeedSimilarity(ctx); err != nil { | ||
| 30 | + c.engine.logger.Error("feed similarity failed", "error", err) | ||
| 31 | + } | ||
| 32 | + if err := c.engine.ComputeUserSimilarity(ctx); err != nil { | ||
| 33 | + c.engine.logger.Error("user similarity failed", "error", err) | ||
| 34 | + } | ||
| 35 | + if err := c.engine.ComputeRecommendations(ctx); err != nil { | ||
| 36 | + c.engine.logger.Error("recommendations failed", "error", err) | ||
| 37 | + } | ||
| 38 | + c.engine.mu.Unlock() | ||
| 39 | + } | ||
| 27 | 40 | ||
| 28 | metrics.ClusterRuns.Inc() | 41 | metrics.ClusterRuns.Inc() |
| 29 | metrics.ClusterDuration.Observe(time.Since(start).Seconds()) | 42 | metrics.ClusterDuration.Observe(time.Since(start).Seconds()) |
modified
internal/cluster/jaccard.go +148 -18 | @@ -13,24 +13,6 @@ type Engine struct { | ||
| 13 | 13 | mu sync.Mutex |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | -func (e *Engine) ComputeAll(ctx context.Context) { | |
| 17 | - if !e.mu.TryLock() { | |
| 18 | - e.logger.Info("skipping ComputeAll: already in progress") | |
| 19 | - return | |
| 20 | - } | |
| 21 | - defer e.mu.Unlock() | |
| 22 | - | |
| 23 | - if err := e.ComputeFeedSimilarity(ctx); err != nil { | |
| 24 | - e.logger.Error("feed similarity failed", "error", err) | |
| 25 | - } | |
| 26 | - if err := e.ComputeUserSimilarity(ctx); err != nil { | |
| 27 | - e.logger.Error("user similarity failed", "error", err) | |
| 28 | - } | |
| 29 | - if err := e.ComputeRecommendations(ctx); err != nil { | |
| 30 | - e.logger.Error("recommendations failed", "error", err) | |
| 31 | - } | |
| 32 | -} | |
| 33 | - | |
| 34 | 16 | func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { |
| 35 | 17 | tx, err := e.db.BeginTx(ctx, nil) |
| 36 | 18 | if err != nil { |
| @@ -73,6 +55,21 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { | ||
| 73 | 55 | return tx.Commit() |
| 74 | 56 | } |
| 75 | 57 | |
| 58 | +func (e *Engine) ComputeForUser(ctx context.Context, userDID string) { | |
| 59 | + if !e.mu.TryLock() { | |
| 60 | + e.logger.Info("skipping ComputeForUser: already in progress", "did", userDID) | |
| 61 | + return | |
| 62 | + } | |
| 63 | + defer e.mu.Unlock() | |
| 64 | + | |
| 65 | + if err := e.ComputeUserSimilarityForUser(ctx, userDID); err != nil { | |
| 66 | + e.logger.Error("per-user similarity failed", "error", err, "did", userDID) | |
| 67 | + } | |
| 68 | + if err := e.ComputeRecommendationsForUser(ctx, userDID); err != nil { | |
| 69 | + e.logger.Error("per-user recommendations failed", "error", err, "did", userDID) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 76 | 73 | func NewEngine(db *sql.DB, logger *slog.Logger) *Engine { |
| 77 | 74 | return &Engine{db: db, logger: logger} |
| 78 | 75 | } |
| @@ -161,6 +158,139 @@ func (e *Engine) ComputeUserSimilarity(ctx context.Context) error { | ||
| 161 | 158 | return tx.Commit() |
| 162 | 159 | } |
| 163 | 160 | |
| 161 | +func (e *Engine) ComputeUserSimilarityForUser(ctx context.Context, userDID string) error { | |
| 162 | + tx, err := e.db.BeginTx(ctx, nil) | |
| 163 | + if err != nil { | |
| 164 | + return err | |
| 165 | + } | |
| 166 | + defer func() { _ = tx.Rollback() }() | |
| 167 | + | |
| 168 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity WHERE user_a = ? OR user_b = ?`, userDID, userDID); err != nil { | |
| 169 | + return err | |
| 170 | + } | |
| 171 | + | |
| 172 | + _, err = tx.ExecContext(ctx, ` | |
| 173 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds) | |
| 174 | + SELECT | |
| 175 | + MIN(?, s2.user_did), | |
| 176 | + MAX(?, s2.user_did), | |
| 177 | + CAST(COUNT(*) AS REAL) / ( | |
| 178 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = ?) + | |
| 179 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) - | |
| 180 | + CAST(COUNT(*) AS REAL) | |
| 181 | + ), | |
| 182 | + COUNT(*) | |
| 183 | + FROM subscriptions s1 | |
| 184 | + JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s2.user_did != ? | |
| 185 | + WHERE s1.user_did = ? | |
| 186 | + GROUP BY s2.user_did | |
| 187 | + HAVING COUNT(*) > 0 | |
| 188 | + `, userDID, userDID, userDID, userDID, userDID) | |
| 189 | + if err != nil { | |
| 190 | + return err | |
| 191 | + } | |
| 192 | + | |
| 193 | + _, err = tx.ExecContext(ctx, ` | |
| 194 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds) | |
| 195 | + SELECT | |
| 196 | + MIN(?, f.target_did), | |
| 197 | + MAX(?, f.target_did), | |
| 198 | + 0.5, | |
| 199 | + 0 | |
| 200 | + FROM follows f | |
| 201 | + WHERE f.user_did = ? AND f.target_did != ? | |
| 202 | + GROUP BY MIN(?, f.target_did), MAX(?, f.target_did) | |
| 203 | + ON CONFLICT(user_a, user_b) DO UPDATE SET | |
| 204 | + jaccard = jaccard + 0.5 | |
| 205 | + `, userDID, userDID, userDID, userDID, userDID, userDID) | |
| 206 | + if err != nil { | |
| 207 | + return err | |
| 208 | + } | |
| 209 | + | |
| 210 | + e.logger.Info("per-user similarity computed", "did", userDID) | |
| 211 | + return tx.Commit() | |
| 212 | +} | |
| 213 | + | |
| 214 | +func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID string) error { | |
| 215 | + tx, err := e.db.BeginTx(ctx, nil) | |
| 216 | + if err != nil { | |
| 217 | + return err | |
| 218 | + } | |
| 219 | + defer func() { _ = tx.Rollback() }() | |
| 220 | + | |
| 221 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_feed_recommendations WHERE user_did = ?`, userDID); err != nil { | |
| 222 | + return err | |
| 223 | + } | |
| 224 | + | |
| 225 | + _, err = tx.ExecContext(ctx, ` | |
| 226 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score) | |
| 227 | + SELECT ?, s.feed_url, SUM(us.jaccard) AS score | |
| 228 | + FROM user_similarity us | |
| 229 | + JOIN subscriptions s ON s.user_did = CASE | |
| 230 | + WHEN us.user_a = ? THEN us.user_b | |
| 231 | + ELSE us.user_a | |
| 232 | + END | |
| 233 | + WHERE (us.user_a = ? OR us.user_b = ?) | |
| 234 | + AND us.jaccard > 0.2 | |
| 235 | + AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | |
| 236 | + GROUP BY s.feed_url | |
| 237 | + ORDER BY score DESC | |
| 238 | + `, userDID, userDID, userDID, userDID, userDID) | |
| 239 | + if err != nil { | |
| 240 | + return err | |
| 241 | + } | |
| 242 | + | |
| 243 | + if err := tx.Commit(); err != nil { | |
| 244 | + return err | |
| 245 | + } | |
| 246 | + | |
| 247 | + if err := e.computeArticleRecommendationsForUser(ctx, userDID); err != nil { | |
| 248 | + return err | |
| 249 | + } | |
| 250 | + | |
| 251 | + e.logger.Info("per-user recommendations computed", "did", userDID) | |
| 252 | + return nil | |
| 253 | +} | |
| 254 | + | |
| 255 | +func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userDID string) error { | |
| 256 | + tx, err := e.db.BeginTx(ctx, nil) | |
| 257 | + if err != nil { | |
| 258 | + return err | |
| 259 | + } | |
| 260 | + defer func() { _ = tx.Rollback() }() | |
| 261 | + | |
| 262 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_article_recommendations WHERE user_did = ?`, userDID); err != nil { | |
| 263 | + return err | |
| 264 | + } | |
| 265 | + | |
| 266 | + _, err = tx.ExecContext(ctx, ` | |
| 267 | + INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score) | |
| 268 | + SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score | |
| 269 | + FROM ( | |
| 270 | + SELECT us.user_b AS peer, us.jaccard | |
| 271 | + FROM user_similarity us WHERE us.user_a = ? AND us.jaccard > 0.2 | |
| 272 | + UNION ALL | |
| 273 | + SELECT us.user_a AS peer, us.jaccard | |
| 274 | + FROM user_similarity us WHERE us.user_b = ? AND us.jaccard > 0.2 | |
| 275 | + ) us | |
| 276 | + JOIN likes l ON l.author_did = us.peer | |
| 277 | + WHERE NOT EXISTS ( | |
| 278 | + SELECT 1 FROM subscriptions sub WHERE sub.user_did = ? AND sub.feed_url = l.feed_url | |
| 279 | + ) | |
| 280 | + AND NOT EXISTS ( | |
| 281 | + SELECT 1 FROM likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url | |
| 282 | + ) | |
| 283 | + GROUP BY l.feed_url, l.article_url | |
| 284 | + HAVING COUNT(*) > 0 | |
| 285 | + ORDER BY score DESC | |
| 286 | + `, userDID, userDID, userDID, userDID, userDID) | |
| 287 | + if err != nil { | |
| 288 | + return err | |
| 289 | + } | |
| 290 | + | |
| 291 | + return tx.Commit() | |
| 292 | +} | |
| 293 | + | |
| 164 | 294 | func (e *Engine) ComputeRecommendations(ctx context.Context) error { |
| 165 | 295 | tx, err := e.db.BeginTx(ctx, nil) |
| 166 | 296 | if err != nil { |
| @@ -13,24 +13,6 @@ type Engine struct { | |||
| 13 | mu sync.Mutex | 13 | mu sync.Mutex |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | -func (e *Engine) ComputeAll(ctx context.Context) { | ||
| 17 | - if !e.mu.TryLock() { | ||
| 18 | - e.logger.Info("skipping ComputeAll: already in progress") | ||
| 19 | - return | ||
| 20 | - } | ||
| 21 | - defer e.mu.Unlock() | ||
| 22 | - | ||
| 23 | - if err := e.ComputeFeedSimilarity(ctx); err != nil { | ||
| 24 | - e.logger.Error("feed similarity failed", "error", err) | ||
| 25 | - } | ||
| 26 | - if err := e.ComputeUserSimilarity(ctx); err != nil { | ||
| 27 | - e.logger.Error("user similarity failed", "error", err) | ||
| 28 | - } | ||
| 29 | - if err := e.ComputeRecommendations(ctx); err != nil { | ||
| 30 | - e.logger.Error("recommendations failed", "error", err) | ||
| 31 | - } | ||
| 32 | -} | ||
| 33 | - | ||
| 34 | func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { | 16 | func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { |
| 35 | tx, err := e.db.BeginTx(ctx, nil) | 17 | tx, err := e.db.BeginTx(ctx, nil) |
| 36 | if err != nil { | 18 | if err != nil { |
| @@ -73,6 +55,21 @@ func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { | |||
| 73 | return tx.Commit() | 55 | return tx.Commit() |
| 74 | } | 56 | } |
| 75 | 57 | ||
| 58 | +func (e *Engine) ComputeForUser(ctx context.Context, userDID string) { | ||
| 59 | + if !e.mu.TryLock() { | ||
| 60 | + e.logger.Info("skipping ComputeForUser: already in progress", "did", userDID) | ||
| 61 | + return | ||
| 62 | + } | ||
| 63 | + defer e.mu.Unlock() | ||
| 64 | + | ||
| 65 | + if err := e.ComputeUserSimilarityForUser(ctx, userDID); err != nil { | ||
| 66 | + e.logger.Error("per-user similarity failed", "error", err, "did", userDID) | ||
| 67 | + } | ||
| 68 | + if err := e.ComputeRecommendationsForUser(ctx, userDID); err != nil { | ||
| 69 | + e.logger.Error("per-user recommendations failed", "error", err, "did", userDID) | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 76 | func NewEngine(db *sql.DB, logger *slog.Logger) *Engine { | 73 | func NewEngine(db *sql.DB, logger *slog.Logger) *Engine { |
| 77 | return &Engine{db: db, logger: logger} | 74 | return &Engine{db: db, logger: logger} |
| 78 | } | 75 | } |
| @@ -161,6 +158,139 @@ func (e *Engine) ComputeUserSimilarity(ctx context.Context) error { | |||
| 161 | return tx.Commit() | 158 | return tx.Commit() |
| 162 | } | 159 | } |
| 163 | 160 | ||
| 161 | +func (e *Engine) ComputeUserSimilarityForUser(ctx context.Context, userDID string) error { | ||
| 162 | + tx, err := e.db.BeginTx(ctx, nil) | ||
| 163 | + if err != nil { | ||
| 164 | + return err | ||
| 165 | + } | ||
| 166 | + defer func() { _ = tx.Rollback() }() | ||
| 167 | + | ||
| 168 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity WHERE user_a = ? OR user_b = ?`, userDID, userDID); err != nil { | ||
| 169 | + return err | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + _, err = tx.ExecContext(ctx, ` | ||
| 173 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds) | ||
| 174 | + SELECT | ||
| 175 | + MIN(?, s2.user_did), | ||
| 176 | + MAX(?, s2.user_did), | ||
| 177 | + CAST(COUNT(*) AS REAL) / ( | ||
| 178 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = ?) + | ||
| 179 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) - | ||
| 180 | + CAST(COUNT(*) AS REAL) | ||
| 181 | + ), | ||
| 182 | + COUNT(*) | ||
| 183 | + FROM subscriptions s1 | ||
| 184 | + JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s2.user_did != ? | ||
| 185 | + WHERE s1.user_did = ? | ||
| 186 | + GROUP BY s2.user_did | ||
| 187 | + HAVING COUNT(*) > 0 | ||
| 188 | + `, userDID, userDID, userDID, userDID, userDID) | ||
| 189 | + if err != nil { | ||
| 190 | + return err | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + _, err = tx.ExecContext(ctx, ` | ||
| 194 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds) | ||
| 195 | + SELECT | ||
| 196 | + MIN(?, f.target_did), | ||
| 197 | + MAX(?, f.target_did), | ||
| 198 | + 0.5, | ||
| 199 | + 0 | ||
| 200 | + FROM follows f | ||
| 201 | + WHERE f.user_did = ? AND f.target_did != ? | ||
| 202 | + GROUP BY MIN(?, f.target_did), MAX(?, f.target_did) | ||
| 203 | + ON CONFLICT(user_a, user_b) DO UPDATE SET | ||
| 204 | + jaccard = jaccard + 0.5 | ||
| 205 | + `, userDID, userDID, userDID, userDID, userDID, userDID) | ||
| 206 | + if err != nil { | ||
| 207 | + return err | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + e.logger.Info("per-user similarity computed", "did", userDID) | ||
| 211 | + return tx.Commit() | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +func (e *Engine) ComputeRecommendationsForUser(ctx context.Context, userDID string) error { | ||
| 215 | + tx, err := e.db.BeginTx(ctx, nil) | ||
| 216 | + if err != nil { | ||
| 217 | + return err | ||
| 218 | + } | ||
| 219 | + defer func() { _ = tx.Rollback() }() | ||
| 220 | + | ||
| 221 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_feed_recommendations WHERE user_did = ?`, userDID); err != nil { | ||
| 222 | + return err | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + _, err = tx.ExecContext(ctx, ` | ||
| 226 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score) | ||
| 227 | + SELECT ?, s.feed_url, SUM(us.jaccard) AS score | ||
| 228 | + FROM user_similarity us | ||
| 229 | + JOIN subscriptions s ON s.user_did = CASE | ||
| 230 | + WHEN us.user_a = ? THEN us.user_b | ||
| 231 | + ELSE us.user_a | ||
| 232 | + END | ||
| 233 | + WHERE (us.user_a = ? OR us.user_b = ?) | ||
| 234 | + AND us.jaccard > 0.2 | ||
| 235 | + AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | ||
| 236 | + GROUP BY s.feed_url | ||
| 237 | + ORDER BY score DESC | ||
| 238 | + `, userDID, userDID, userDID, userDID, userDID) | ||
| 239 | + if err != nil { | ||
| 240 | + return err | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + if err := tx.Commit(); err != nil { | ||
| 244 | + return err | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + if err := e.computeArticleRecommendationsForUser(ctx, userDID); err != nil { | ||
| 248 | + return err | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + e.logger.Info("per-user recommendations computed", "did", userDID) | ||
| 252 | + return nil | ||
| 253 | +} | ||
| 254 | + | ||
| 255 | +func (e *Engine) computeArticleRecommendationsForUser(ctx context.Context, userDID string) error { | ||
| 256 | + tx, err := e.db.BeginTx(ctx, nil) | ||
| 257 | + if err != nil { | ||
| 258 | + return err | ||
| 259 | + } | ||
| 260 | + defer func() { _ = tx.Rollback() }() | ||
| 261 | + | ||
| 262 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_article_recommendations WHERE user_did = ?`, userDID); err != nil { | ||
| 263 | + return err | ||
| 264 | + } | ||
| 265 | + | ||
| 266 | + _, err = tx.ExecContext(ctx, ` | ||
| 267 | + INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score) | ||
| 268 | + SELECT ?, l.feed_url, l.article_url, SUM(us.jaccard) AS score | ||
| 269 | + FROM ( | ||
| 270 | + SELECT us.user_b AS peer, us.jaccard | ||
| 271 | + FROM user_similarity us WHERE us.user_a = ? AND us.jaccard > 0.2 | ||
| 272 | + UNION ALL | ||
| 273 | + SELECT us.user_a AS peer, us.jaccard | ||
| 274 | + FROM user_similarity us WHERE us.user_b = ? AND us.jaccard > 0.2 | ||
| 275 | + ) us | ||
| 276 | + JOIN likes l ON l.author_did = us.peer | ||
| 277 | + WHERE NOT EXISTS ( | ||
| 278 | + SELECT 1 FROM subscriptions sub WHERE sub.user_did = ? AND sub.feed_url = l.feed_url | ||
| 279 | + ) | ||
| 280 | + AND NOT EXISTS ( | ||
| 281 | + SELECT 1 FROM likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url | ||
| 282 | + ) | ||
| 283 | + GROUP BY l.feed_url, l.article_url | ||
| 284 | + HAVING COUNT(*) > 0 | ||
| 285 | + ORDER BY score DESC | ||
| 286 | + `, userDID, userDID, userDID, userDID, userDID) | ||
| 287 | + if err != nil { | ||
| 288 | + return err | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + return tx.Commit() | ||
| 292 | +} | ||
| 293 | + | ||
| 164 | func (e *Engine) ComputeRecommendations(ctx context.Context) error { | 294 | func (e *Engine) ComputeRecommendations(ctx context.Context) error { |
| 165 | tx, err := e.db.BeginTx(ctx, nil) | 295 | tx, err := e.db.BeginTx(ctx, nil) |
| 166 | if err != nil { | 296 | if err != nil { |
modified
internal/cluster/jaccard_test.go +165 -0 | @@ -140,3 +140,168 @@ func TestComputeRecommendations_NoSelfRecommendations(t *testing.T) { | ||
| 140 | 140 | "should not recommend a feed the user already subscribes to") |
| 141 | 141 | } |
| 142 | 142 | } |
| 143 | + | |
| 144 | +func TestComputeUserSimilarityForUser(t *testing.T) { | |
| 145 | + ctx := context.Background() | |
| 146 | + database := setupClusterTestDB(t) | |
| 147 | + seedClusterData(t, ctx, database) | |
| 148 | + | |
| 149 | + engine := NewEngine(database.DB, slog.Default()) | |
| 150 | + | |
| 151 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:alice")) | |
| 152 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:bob")) | |
| 153 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:carol")) | |
| 154 | + | |
| 155 | + var count int | |
| 156 | + assert.NilError(t, database.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_similarity`).Scan(&count)) | |
| 157 | + assert.Equal(t, count, 2, "alice-bob and alice-carol share feeds") | |
| 158 | +} | |
| 159 | + | |
| 160 | +func TestComputeUserSimilarityForUser_MatchesFullCompute(t *testing.T) { | |
| 161 | + ctx := context.Background() | |
| 162 | + | |
| 163 | + type simPair struct { | |
| 164 | + userA, userB string | |
| 165 | + jaccard float64 | |
| 166 | + commonFeeds int | |
| 167 | + } | |
| 168 | + | |
| 169 | + readAll := func(t *testing.T, database *db.DB) []simPair { | |
| 170 | + t.Helper() | |
| 171 | + rows, err := database.QueryContext(ctx, `SELECT user_a, user_b, jaccard, common_feeds FROM user_similarity ORDER BY user_a, user_b`) | |
| 172 | + assert.NilError(t, err) | |
| 173 | + defer rows.Close() | |
| 174 | + var result []simPair | |
| 175 | + for rows.Next() { | |
| 176 | + var p simPair | |
| 177 | + assert.NilError(t, rows.Scan(&p.userA, &p.userB, &p.jaccard, &p.commonFeeds)) | |
| 178 | + result = append(result, p) | |
| 179 | + } | |
| 180 | + assert.NilError(t, rows.Err()) | |
| 181 | + return result | |
| 182 | + } | |
| 183 | + | |
| 184 | + database1 := setupClusterTestDB(t) | |
| 185 | + seedClusterData(t, ctx, database1) | |
| 186 | + engine1 := NewEngine(database1.DB, slog.Default()) | |
| 187 | + assert.NilError(t, engine1.ComputeUserSimilarity(ctx)) | |
| 188 | + fullPairs := readAll(t, database1) | |
| 189 | + | |
| 190 | + database2 := setupClusterTestDB(t) | |
| 191 | + seedClusterData(t, ctx, database2) | |
| 192 | + engine2 := NewEngine(database2.DB, slog.Default()) | |
| 193 | + assert.NilError(t, engine2.ComputeUserSimilarityForUser(ctx, "did:test:alice")) | |
| 194 | + assert.NilError(t, engine2.ComputeUserSimilarityForUser(ctx, "did:test:bob")) | |
| 195 | + assert.NilError(t, engine2.ComputeUserSimilarityForUser(ctx, "did:test:carol")) | |
| 196 | + incrPairs := readAll(t, database2) | |
| 197 | + | |
| 198 | + assert.Equal(t, len(fullPairs), len(incrPairs), "same number of pairs") | |
| 199 | + for i := range fullPairs { | |
| 200 | + assert.Equal(t, fullPairs[i].userA, incrPairs[i].userA) | |
| 201 | + assert.Equal(t, fullPairs[i].userB, incrPairs[i].userB) | |
| 202 | + assert.Equal(t, fullPairs[i].commonFeeds, incrPairs[i].commonFeeds) | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +func TestComputeUserSimilarityForUser_UpdatesOnSubscriptionChange(t *testing.T) { | |
| 207 | + ctx := context.Background() | |
| 208 | + database := setupClusterTestDB(t) | |
| 209 | + seedClusterData(t, ctx, database) | |
| 210 | + | |
| 211 | + engine := NewEngine(database.DB, slog.Default()) | |
| 212 | + assert.NilError(t, engine.ComputeUserSimilarity(ctx)) | |
| 213 | + | |
| 214 | + _, err := database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:carol", "https://a.com/feed") | |
| 215 | + assert.NilError(t, err) | |
| 216 | + | |
| 217 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:carol")) | |
| 218 | + | |
| 219 | + var jaccard float64 | |
| 220 | + var common int | |
| 221 | + assert.NilError(t, database.QueryRowContext(ctx, | |
| 222 | + `SELECT jaccard, common_feeds FROM user_similarity WHERE user_a = ? AND user_b = ?`, | |
| 223 | + "did:test:alice", "did:test:carol").Scan(&jaccard, &common)) | |
| 224 | + assert.Equal(t, common, 2, "alice and carol now share feed A and feed C") | |
| 225 | +} | |
| 226 | + | |
| 227 | +func TestComputeUserSimilarityForUser_WithFollowBoost(t *testing.T) { | |
| 228 | + ctx := context.Background() | |
| 229 | + database := setupClusterTestDB(t) | |
| 230 | + seedClusterData(t, ctx, database) | |
| 231 | + | |
| 232 | + _, err := database.ExecContext(ctx, `INSERT INTO follows (user_did, target_did) VALUES (?, ?)`, "did:test:alice", "did:test:bob") | |
| 233 | + assert.NilError(t, err) | |
| 234 | + | |
| 235 | + engine := NewEngine(database.DB, slog.Default()) | |
| 236 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:alice")) | |
| 237 | + | |
| 238 | + var jaccard float64 | |
| 239 | + assert.NilError(t, database.QueryRowContext(ctx, | |
| 240 | + `SELECT jaccard FROM user_similarity WHERE user_a = ? AND user_b = ?`, | |
| 241 | + "did:test:alice", "did:test:bob").Scan(&jaccard)) | |
| 242 | + assert.Assert(t, jaccard > 0.6, "follow boost should add 0.5 to subscription jaccard, got %f", jaccard) | |
| 243 | +} | |
| 244 | + | |
| 245 | +func TestComputeRecommendationsForUser(t *testing.T) { | |
| 246 | + ctx := context.Background() | |
| 247 | + database := setupClusterTestDB(t) | |
| 248 | + seedClusterData(t, ctx, database) | |
| 249 | + | |
| 250 | + engine := NewEngine(database.DB, slog.Default()) | |
| 251 | + assert.NilError(t, engine.ComputeUserSimilarity(ctx)) | |
| 252 | + assert.NilError(t, engine.ComputeRecommendationsForUser(ctx, "did:test:carol")) | |
| 253 | + | |
| 254 | + recs, err := engine.GetFeedRecommendations(ctx, "did:test:carol", 10) | |
| 255 | + assert.NilError(t, err) | |
| 256 | + assert.Assert(t, len(recs) > 0, "carol should get feed recommendations") | |
| 257 | + | |
| 258 | + var found bool | |
| 259 | + for _, r := range recs { | |
| 260 | + if r["feed_url"] == "https://a.com/feed" || r["feed_url"] == "https://b.com/feed" { | |
| 261 | + found = true | |
| 262 | + } | |
| 263 | + } | |
| 264 | + assert.Assert(t, found, "carol should be recommended feeds from alice") | |
| 265 | +} | |
| 266 | + | |
| 267 | +func TestComputeRecommendationsForUser_MatchesFullCompute(t *testing.T) { | |
| 268 | + ctx := context.Background() | |
| 269 | + | |
| 270 | + readRecs := func(t *testing.T, database *db.DB, did string) map[string]float64 { | |
| 271 | + t.Helper() | |
| 272 | + rows, err := database.QueryContext(ctx, | |
| 273 | + `SELECT feed_url, score FROM user_feed_recommendations WHERE user_did = ? ORDER BY feed_url`, did) | |
| 274 | + assert.NilError(t, err) | |
| 275 | + defer rows.Close() | |
| 276 | + result := map[string]float64{} | |
| 277 | + for rows.Next() { | |
| 278 | + var url string | |
| 279 | + var score float64 | |
| 280 | + assert.NilError(t, rows.Scan(&url, &score)) | |
| 281 | + result[url] = score | |
| 282 | + } | |
| 283 | + assert.NilError(t, rows.Err()) | |
| 284 | + return result | |
| 285 | + } | |
| 286 | + | |
| 287 | + database1 := setupClusterTestDB(t) | |
| 288 | + seedClusterData(t, ctx, database1) | |
| 289 | + engine1 := NewEngine(database1.DB, slog.Default()) | |
| 290 | + assert.NilError(t, engine1.ComputeUserSimilarity(ctx)) | |
| 291 | + assert.NilError(t, engine1.ComputeRecommendations(ctx)) | |
| 292 | + fullRecs := readRecs(t, database1, "did:test:carol") | |
| 293 | + | |
| 294 | + database2 := setupClusterTestDB(t) | |
| 295 | + seedClusterData(t, ctx, database2) | |
| 296 | + engine2 := NewEngine(database2.DB, slog.Default()) | |
| 297 | + assert.NilError(t, engine2.ComputeUserSimilarity(ctx)) | |
| 298 | + assert.NilError(t, engine2.ComputeRecommendationsForUser(ctx, "did:test:carol")) | |
| 299 | + incrRecs := readRecs(t, database2, "did:test:carol") | |
| 300 | + | |
| 301 | + assert.Equal(t, len(fullRecs), len(incrRecs), "same number of recommendations") | |
| 302 | + for url, score := range fullRecs { | |
| 303 | + incrScore, ok := incrRecs[url] | |
| 304 | + assert.Assert(t, ok, "missing recommendation for %s", url) | |
| 305 | + assert.Equal(t, score, incrScore, "score mismatch for %s", url) | |
| 306 | + } | |
| 307 | +} | |
| @@ -140,3 +140,168 @@ func TestComputeRecommendations_NoSelfRecommendations(t *testing.T) { | |||
| 140 | "should not recommend a feed the user already subscribes to") | 140 | "should not recommend a feed the user already subscribes to") |
| 141 | } | 141 | } |
| 142 | } | 142 | } |
| 143 | + | ||
| 144 | +func TestComputeUserSimilarityForUser(t *testing.T) { | ||
| 145 | + ctx := context.Background() | ||
| 146 | + database := setupClusterTestDB(t) | ||
| 147 | + seedClusterData(t, ctx, database) | ||
| 148 | + | ||
| 149 | + engine := NewEngine(database.DB, slog.Default()) | ||
| 150 | + | ||
| 151 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:alice")) | ||
| 152 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:bob")) | ||
| 153 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:carol")) | ||
| 154 | + | ||
| 155 | + var count int | ||
| 156 | + assert.NilError(t, database.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_similarity`).Scan(&count)) | ||
| 157 | + assert.Equal(t, count, 2, "alice-bob and alice-carol share feeds") | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +func TestComputeUserSimilarityForUser_MatchesFullCompute(t *testing.T) { | ||
| 161 | + ctx := context.Background() | ||
| 162 | + | ||
| 163 | + type simPair struct { | ||
| 164 | + userA, userB string | ||
| 165 | + jaccard float64 | ||
| 166 | + commonFeeds int | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + readAll := func(t *testing.T, database *db.DB) []simPair { | ||
| 170 | + t.Helper() | ||
| 171 | + rows, err := database.QueryContext(ctx, `SELECT user_a, user_b, jaccard, common_feeds FROM user_similarity ORDER BY user_a, user_b`) | ||
| 172 | + assert.NilError(t, err) | ||
| 173 | + defer rows.Close() | ||
| 174 | + var result []simPair | ||
| 175 | + for rows.Next() { | ||
| 176 | + var p simPair | ||
| 177 | + assert.NilError(t, rows.Scan(&p.userA, &p.userB, &p.jaccard, &p.commonFeeds)) | ||
| 178 | + result = append(result, p) | ||
| 179 | + } | ||
| 180 | + assert.NilError(t, rows.Err()) | ||
| 181 | + return result | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + database1 := setupClusterTestDB(t) | ||
| 185 | + seedClusterData(t, ctx, database1) | ||
| 186 | + engine1 := NewEngine(database1.DB, slog.Default()) | ||
| 187 | + assert.NilError(t, engine1.ComputeUserSimilarity(ctx)) | ||
| 188 | + fullPairs := readAll(t, database1) | ||
| 189 | + | ||
| 190 | + database2 := setupClusterTestDB(t) | ||
| 191 | + seedClusterData(t, ctx, database2) | ||
| 192 | + engine2 := NewEngine(database2.DB, slog.Default()) | ||
| 193 | + assert.NilError(t, engine2.ComputeUserSimilarityForUser(ctx, "did:test:alice")) | ||
| 194 | + assert.NilError(t, engine2.ComputeUserSimilarityForUser(ctx, "did:test:bob")) | ||
| 195 | + assert.NilError(t, engine2.ComputeUserSimilarityForUser(ctx, "did:test:carol")) | ||
| 196 | + incrPairs := readAll(t, database2) | ||
| 197 | + | ||
| 198 | + assert.Equal(t, len(fullPairs), len(incrPairs), "same number of pairs") | ||
| 199 | + for i := range fullPairs { | ||
| 200 | + assert.Equal(t, fullPairs[i].userA, incrPairs[i].userA) | ||
| 201 | + assert.Equal(t, fullPairs[i].userB, incrPairs[i].userB) | ||
| 202 | + assert.Equal(t, fullPairs[i].commonFeeds, incrPairs[i].commonFeeds) | ||
| 203 | + } | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +func TestComputeUserSimilarityForUser_UpdatesOnSubscriptionChange(t *testing.T) { | ||
| 207 | + ctx := context.Background() | ||
| 208 | + database := setupClusterTestDB(t) | ||
| 209 | + seedClusterData(t, ctx, database) | ||
| 210 | + | ||
| 211 | + engine := NewEngine(database.DB, slog.Default()) | ||
| 212 | + assert.NilError(t, engine.ComputeUserSimilarity(ctx)) | ||
| 213 | + | ||
| 214 | + _, err := database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:carol", "https://a.com/feed") | ||
| 215 | + assert.NilError(t, err) | ||
| 216 | + | ||
| 217 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:carol")) | ||
| 218 | + | ||
| 219 | + var jaccard float64 | ||
| 220 | + var common int | ||
| 221 | + assert.NilError(t, database.QueryRowContext(ctx, | ||
| 222 | + `SELECT jaccard, common_feeds FROM user_similarity WHERE user_a = ? AND user_b = ?`, | ||
| 223 | + "did:test:alice", "did:test:carol").Scan(&jaccard, &common)) | ||
| 224 | + assert.Equal(t, common, 2, "alice and carol now share feed A and feed C") | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +func TestComputeUserSimilarityForUser_WithFollowBoost(t *testing.T) { | ||
| 228 | + ctx := context.Background() | ||
| 229 | + database := setupClusterTestDB(t) | ||
| 230 | + seedClusterData(t, ctx, database) | ||
| 231 | + | ||
| 232 | + _, err := database.ExecContext(ctx, `INSERT INTO follows (user_did, target_did) VALUES (?, ?)`, "did:test:alice", "did:test:bob") | ||
| 233 | + assert.NilError(t, err) | ||
| 234 | + | ||
| 235 | + engine := NewEngine(database.DB, slog.Default()) | ||
| 236 | + assert.NilError(t, engine.ComputeUserSimilarityForUser(ctx, "did:test:alice")) | ||
| 237 | + | ||
| 238 | + var jaccard float64 | ||
| 239 | + assert.NilError(t, database.QueryRowContext(ctx, | ||
| 240 | + `SELECT jaccard FROM user_similarity WHERE user_a = ? AND user_b = ?`, | ||
| 241 | + "did:test:alice", "did:test:bob").Scan(&jaccard)) | ||
| 242 | + assert.Assert(t, jaccard > 0.6, "follow boost should add 0.5 to subscription jaccard, got %f", jaccard) | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +func TestComputeRecommendationsForUser(t *testing.T) { | ||
| 246 | + ctx := context.Background() | ||
| 247 | + database := setupClusterTestDB(t) | ||
| 248 | + seedClusterData(t, ctx, database) | ||
| 249 | + | ||
| 250 | + engine := NewEngine(database.DB, slog.Default()) | ||
| 251 | + assert.NilError(t, engine.ComputeUserSimilarity(ctx)) | ||
| 252 | + assert.NilError(t, engine.ComputeRecommendationsForUser(ctx, "did:test:carol")) | ||
| 253 | + | ||
| 254 | + recs, err := engine.GetFeedRecommendations(ctx, "did:test:carol", 10) | ||
| 255 | + assert.NilError(t, err) | ||
| 256 | + assert.Assert(t, len(recs) > 0, "carol should get feed recommendations") | ||
| 257 | + | ||
| 258 | + var found bool | ||
| 259 | + for _, r := range recs { | ||
| 260 | + if r["feed_url"] == "https://a.com/feed" || r["feed_url"] == "https://b.com/feed" { | ||
| 261 | + found = true | ||
| 262 | + } | ||
| 263 | + } | ||
| 264 | + assert.Assert(t, found, "carol should be recommended feeds from alice") | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +func TestComputeRecommendationsForUser_MatchesFullCompute(t *testing.T) { | ||
| 268 | + ctx := context.Background() | ||
| 269 | + | ||
| 270 | + readRecs := func(t *testing.T, database *db.DB, did string) map[string]float64 { | ||
| 271 | + t.Helper() | ||
| 272 | + rows, err := database.QueryContext(ctx, | ||
| 273 | + `SELECT feed_url, score FROM user_feed_recommendations WHERE user_did = ? ORDER BY feed_url`, did) | ||
| 274 | + assert.NilError(t, err) | ||
| 275 | + defer rows.Close() | ||
| 276 | + result := map[string]float64{} | ||
| 277 | + for rows.Next() { | ||
| 278 | + var url string | ||
| 279 | + var score float64 | ||
| 280 | + assert.NilError(t, rows.Scan(&url, &score)) | ||
| 281 | + result[url] = score | ||
| 282 | + } | ||
| 283 | + assert.NilError(t, rows.Err()) | ||
| 284 | + return result | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + database1 := setupClusterTestDB(t) | ||
| 288 | + seedClusterData(t, ctx, database1) | ||
| 289 | + engine1 := NewEngine(database1.DB, slog.Default()) | ||
| 290 | + assert.NilError(t, engine1.ComputeUserSimilarity(ctx)) | ||
| 291 | + assert.NilError(t, engine1.ComputeRecommendations(ctx)) | ||
| 292 | + fullRecs := readRecs(t, database1, "did:test:carol") | ||
| 293 | + | ||
| 294 | + database2 := setupClusterTestDB(t) | ||
| 295 | + seedClusterData(t, ctx, database2) | ||
| 296 | + engine2 := NewEngine(database2.DB, slog.Default()) | ||
| 297 | + assert.NilError(t, engine2.ComputeUserSimilarity(ctx)) | ||
| 298 | + assert.NilError(t, engine2.ComputeRecommendationsForUser(ctx, "did:test:carol")) | ||
| 299 | + incrRecs := readRecs(t, database2, "did:test:carol") | ||
| 300 | + | ||
| 301 | + assert.Equal(t, len(fullRecs), len(incrRecs), "same number of recommendations") | ||
| 302 | + for url, score := range fullRecs { | ||
| 303 | + incrScore, ok := incrRecs[url] | ||
| 304 | + assert.Assert(t, ok, "missing recommendation for %s", url) | ||
| 305 | + assert.Equal(t, score, incrScore, "score mismatch for %s", url) | ||
| 306 | + } | ||
| 307 | +} | ||
modified
internal/server/profile_handler.go +22 -1 | @@ -2,12 +2,33 @@ package server | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | + "strings" | |
| 5 | 6 | |
| 6 | 7 | "github.com/go-chi/chi/v5" |
| 8 | + | |
| 9 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 7 | 10 | ) |
| 8 | 11 | |
| 9 | 12 | func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { |
| 10 | - did := chi.URLParam(r, "did") | |
| 13 | + param := chi.URLParam(r, "did") | |
| 14 | + | |
| 15 | + var did string | |
| 16 | + if strings.HasPrefix(param, "did:") { | |
| 17 | + did = param | |
| 18 | + } else { | |
| 19 | + profileUser, err := s.db.GetUserByHandle(r.Context(), param) | |
| 20 | + if err == nil { | |
| 21 | + did = profileUser.DID | |
| 22 | + } else { | |
| 23 | + resolved, err := atproto.ResolveHandle(r.Context(), param) | |
| 24 | + if err != nil { | |
| 25 | + http.Error(w, "handle not found", http.StatusNotFound) | |
| 26 | + return | |
| 27 | + } | |
| 28 | + did = resolved | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 11 | 32 | profileUser, err := s.db.GetUser(r.Context(), did) |
| 12 | 33 | if err != nil { |
| 13 | 34 | http.Error(w, err.Error(), http.StatusNotFound) |
| @@ -2,12 +2,33 @@ package server | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | 4 | "net/http" |
| 5 | + "strings" | ||
| 5 | 6 | ||
| 6 | "github.com/go-chi/chi/v5" | 7 | "github.com/go-chi/chi/v5" |
| 8 | + | ||
| 9 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 7 | ) | 10 | ) |
| 8 | 11 | ||
| 9 | func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | 12 | func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { |
| 10 | - did := chi.URLParam(r, "did") | 13 | + param := chi.URLParam(r, "did") |
| 14 | + | ||
| 15 | + var did string | ||
| 16 | + if strings.HasPrefix(param, "did:") { | ||
| 17 | + did = param | ||
| 18 | + } else { | ||
| 19 | + profileUser, err := s.db.GetUserByHandle(r.Context(), param) | ||
| 20 | + if err == nil { | ||
| 21 | + did = profileUser.DID | ||
| 22 | + } else { | ||
| 23 | + resolved, err := atproto.ResolveHandle(r.Context(), param) | ||
| 24 | + if err != nil { | ||
| 25 | + http.Error(w, "handle not found", http.StatusNotFound) | ||
| 26 | + return | ||
| 27 | + } | ||
| 28 | + did = resolved | ||
| 29 | + } | ||
| 30 | + } | ||
| 31 | + | ||
| 11 | profileUser, err := s.db.GetUser(r.Context(), did) | 32 | profileUser, err := s.db.GetUser(r.Context(), did) |
| 12 | if err != nil { | 33 | if err != nil { |
| 13 | http.Error(w, err.Error(), http.StatusNotFound) | 34 | http.Error(w, err.Error(), http.StatusNotFound) |
modified
internal/server/server.go +3 -6 | @@ -389,13 +389,10 @@ func (s *Server) syncUserInBackground(userDID string, client *atproto.Client) { | ||
| 389 | 389 | s.logger.Error("background sync failed", "error", err, "did", userDID) |
| 390 | 390 | } |
| 391 | 391 | |
| 392 | - if !isNewUser { | |
| 393 | - return | |
| 392 | + if isNewUser { | |
| 393 | + s.refreshUserFeeds(ctx, userDID) | |
| 394 | 394 | } |
| 395 | - | |
| 396 | - // if the user is new, but has value from the PDS, we should backfill and refetch their data. | |
| 397 | - s.refreshUserFeeds(ctx, userDID) | |
| 398 | - s.engine.ComputeAll(ctx) | |
| 395 | + s.engine.ComputeForUser(ctx, userDID) | |
| 399 | 396 | }() |
| 400 | 397 | } |
| 401 | 398 | |
| @@ -389,13 +389,10 @@ func (s *Server) syncUserInBackground(userDID string, client *atproto.Client) { | |||
| 389 | s.logger.Error("background sync failed", "error", err, "did", userDID) | 389 | s.logger.Error("background sync failed", "error", err, "did", userDID) |
| 390 | } | 390 | } |
| 391 | 391 | ||
| 392 | - if !isNewUser { | 392 | + if isNewUser { |
| 393 | - return | 393 | + s.refreshUserFeeds(ctx, userDID) |
| 394 | } | 394 | } |
| 395 | - | 395 | + s.engine.ComputeForUser(ctx, userDID) |
| 396 | - // if the user is new, but has value from the PDS, we should backfill and refetch their data. | ||
| 397 | - s.refreshUserFeeds(ctx, userDID) | ||
| 398 | - s.engine.ComputeAll(ctx) | ||
| 399 | }() | 396 | }() |
| 400 | } | 397 | } |
| 401 | 398 | ||