From ed6a383bb9dc70d4d7eb5ffd8770957c9ef1c1d4 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 16 Jan 2026 10:41:29 +0000 Subject: [PATCH] appview: upsert star/reaction/follow records Most service flow will be: 1. start db transaction 2. run db operation 3. run PDS operation 4. rollback db if anything above failed 5. commit transaction If PDS operation succeed, don't try rollback anymore. The ingester will backfill the missed db operations. Signed-off-by: Seongmin Lee --- appview/ingester.go | 30 ++++++++++++------------------ appview/db/follow.go | 15 ++++++++++++--- appview/db/reaction.go | 17 ++++++++++++++--- appview/db/star.go | 13 +++++++++---- appview/models/follow.go | 9 +++++++++ appview/models/reaction.go | 8 ++++++++ appview/state/follow.go | 45 +++++++++++++++++++++++++++------------------ appview/state/reaction.go | 43 ++++++++++++++++++++++++++++--------------- appview/state/star.go | 51 ++++++++++++++++++++++++++++++--------------------- 9 file(s) changed, 149 insertion(s)(+), 82 deletion(s)(-) diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -200,14 +200,14 @@ record := tangled.FeedStar{} unmarshalErr := json.Unmarshal(raw, &record) - star := &models.Star{ + star := models.Star{ Did: did, Rkey: e.Commit.RKey, } switch { case unmarshalErr != nil: - resolved, resolveErr := i.resolveOldFormatStar(raw, star, l) + resolved, resolveErr := i.resolveOldFormatStar(raw, &star, l) if resolveErr != nil { l.Error("invalid record", "newFmtErr", unmarshalErr, "oldFmtErr", resolveErr) return unmarshalErr @@ -236,7 +236,7 @@ return fmt.Errorf("star record has empty subject union") } - err = db.AddStar(i.Db, star) + err = db.UpsertStar(i.Db, star) case jmodels.CommitOperationDelete: err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) } @@ -265,7 +265,7 @@ return err } - err = db.AddFollow(i.Db, &models.Follow{ + err = db.UpsertFollow(i.Db, models.Follow{ UserDid: did, SubjectDid: record.Subject, Rkey: e.Commit.RKey, @@ -1733,21 +1733,15 @@ created = time.Now() } - tx, err := i.Db.Begin() - if err != nil { - return fmt.Errorf("failed to start transaction: %w", err) + reaction := models.Reaction{ + ReactedByDid: did, + Rkey: rkey, + ThreadAt: subjectUri, + Kind: kind, + Created: created, } - defer tx.Rollback() - - if err := db.DeleteReactionByRkey(tx, did, rkey); err != nil { - return fmt.Errorf("failed to clear existing reaction: %w", err) - } - if err := db.AddReaction(tx, did, subjectUri, kind, rkey, created); err != nil { - return fmt.Errorf("failed to add reaction: %w", err) - } - - if err := tx.Commit(); err != nil { - return err + if err := db.UpsertReaction(i.Db, reaction); err != nil { + return fmt.Errorf("failed to upsert reaction: %w", err) } case jmodels.CommitOperationDelete: diff --git a/appview/db/follow.go b/appview/db/follow.go --- a/appview/db/follow.go +++ b/appview/db/follow.go @@ -11,9 +11,18 @@ "tangled.org/core/orm" ) -func AddFollow(e Execer, follow *models.Follow) error { - query := `insert or ignore into follows (did, subject_did, rkey) values (?, ?, ?)` - _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) +func UpsertFollow(e Execer, follow models.Follow) error { + _, err := e.Exec( + `insert into follows (did, rkey, subject_did, created) + values (?, ?, ?, ?) + on conflict(did, rkey) do update set + subject_did = excluded.subject_did, + created = excluded.created`, + follow.UserDid, + follow.Rkey, + follow.SubjectDid, + follow.FollowedAt.Format(time.RFC3339), + ) return err } diff --git a/appview/db/reaction.go b/appview/db/reaction.go --- a/appview/db/reaction.go +++ b/appview/db/reaction.go @@ -10,9 +10,20 @@ "tangled.org/core/orm" ) -func AddReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind, rkey string, created time.Time) error { - query := `insert or ignore into reactions (did, subject_at, kind, rkey, created) values (?, ?, ?, ?, ?)` - _, err := e.Exec(query, did, subjectAt, kind, rkey, created.UTC().Format(time.RFC3339)) +func UpsertReaction(e Execer, reaction models.Reaction) error { + _, err := e.Exec( + `insert into reactions (did, rkey, subject_at, kind, created) + values (?, ?, ?, ?, ?) + on conflict(did, rkey) do update set + subject_at = excluded.subject_at, + kind = excluded.kind, + created = excluded.created`, + reaction.ReactedByDid, + reaction.Rkey, + reaction.ThreadAt, + reaction.Kind, + reaction.Created.Format(time.RFC3339), + ) return err } diff --git a/appview/db/star.go b/appview/db/star.go --- a/appview/db/star.go +++ b/appview/db/star.go @@ -13,14 +13,19 @@ "tangled.org/core/orm" ) -func AddStar(e Execer, star *models.Star) error { - query := `insert or ignore into stars (did, subject_type, subject, rkey) values (?, ?, ?, ?)` +func UpsertStar(e Execer, star models.Star) error { _, err := e.Exec( - query, + `insert into stars (did, rkey, subject_type, subject, created) + values (?, ?, ?, ?, ?) + on conflict(did, rkey) do update set + subject_type = excluded.subject_type, + subject = excluded.subject, + created = excluded.created`, star.Did, + star.Rkey, string(star.SubjectType), star.Subject, - star.Rkey, + star.Created.Format(time.RFC3339), ) return err } diff --git a/appview/models/follow.go b/appview/models/follow.go --- a/appview/models/follow.go +++ b/appview/models/follow.go @@ -2,6 +2,8 @@ import ( "time" + + "tangled.org/core/api/tangled" ) type Follow struct { @@ -9,6 +11,13 @@ SubjectDid string FollowedAt time.Time Rkey string +} + +func (f *Follow) AsRecord() tangled.GraphFollow { + return tangled.GraphFollow{ + Subject: f.SubjectDid, + CreatedAt: f.FollowedAt.Format(time.RFC3339), + } } type FollowStats struct { diff --git a/appview/models/reaction.go b/appview/models/reaction.go --- a/appview/models/reaction.go +++ b/appview/models/reaction.go @@ -59,6 +59,14 @@ Kind ReactionKind } +func (r *Reaction) AsRecord() tangled.FeedReaction { + return tangled.FeedReaction{ + Subject: r.ThreadAt.String(), + Reaction: r.Kind.String(), + CreatedAt: r.Created.Format(time.RFC3339), + } +} + type ReactionDisplayData struct { Count int Users []string diff --git a/appview/state/follow.go b/appview/state/follow.go --- a/appview/state/follow.go +++ b/appview/state/follow.go @@ -43,17 +43,33 @@ switch r.Method { case http.MethodPost: - createdAt := time.Now().Format(time.RFC3339) - rkey := tid.TID() + follow := models.Follow{ + UserDid: currentUser.Did, + SubjectDid: subjectIdent.DID.String(), + Rkey: tid.TID(), + FollowedAt: time.Now(), + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + s.logger.Error("failed to start transaction", "err", err) + return + } + defer tx.Rollback() + + if err := db.UpsertFollow(tx, follow); err != nil { + s.logger.Error("failed to follow", "err", err) + return + } + + record := follow.AsRecord() resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: currentUser.Did, - Rkey: rkey, + Rkey: follow.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.GraphFollow{ - Subject: subjectIdent.DID.String(), - CreatedAt: createdAt, - }}, + Val: &record, + }, }) if err != nil { l.Error("failed to create atproto record", "err", err) @@ -62,19 +78,12 @@ l.Info("created atproto record", "uri", resp.Uri) - follow := &models.Follow{ - UserDid: currentUser.Did, - SubjectDid: subjectIdent.DID.String(), - Rkey: rkey, + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } - err = db.AddFollow(s.db, follow) - if err != nil { - l.Error("failed to follow", "err", err) - return - } - - s.notifier.NewFollow(r.Context(), follow) + s.notifier.NewFollow(r.Context(), &follow) followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) if err != nil { diff --git a/appview/state/reaction.go b/appview/state/reaction.go --- a/appview/state/reaction.go +++ b/appview/state/reaction.go @@ -48,37 +48,50 @@ switch r.Method { case http.MethodPost: - createdAt := time.Now() - rkey := tid.TID() + reaction := models.Reaction{ + ReactedByDid: currentUser.Did, + Rkey: tid.TID(), + Kind: reactionKind, + ThreadAt: subjectUri, + Created: time.Now(), + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + s.logger.Error("failed to start transaction", "err", err) + return + } + defer tx.Rollback() + + if err := db.UpsertReaction(tx, reaction); err != nil { + l.Error("db: failed to upsert reaction", "err", err) + return + } + + record := reaction.AsRecord() resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.FeedReactionNSID, Repo: currentUser.Did, - Rkey: rkey, + Rkey: reaction.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.FeedReaction{ - Subject: subjectUri.String(), - Reaction: reactionKind.String(), - CreatedAt: createdAt.Format(time.RFC3339), - }, + Val: &record, }, }) if err != nil { l.Error("failed to create atproto record", "err", err) return } + l.Info("created atproto record", "uri", resp.Uri) - err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey, createdAt) - if err != nil { - l.Error("failed to react", "err", err) - return + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) if err != nil { - l.Error("failed to get reactions", "subjectUri", subjectUri, "err", err) + l.Error("failed to get reactions", "subject", subjectUri) } - - l.Info("created atproto record", "uri", resp.Uri) s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ Kind: reactionKind, diff --git a/appview/state/star.go b/appview/state/star.go --- a/appview/state/star.go +++ b/appview/state/star.go @@ -77,19 +77,36 @@ switch r.Method { case http.MethodPost: - createdAt := time.Now().Format(time.RFC3339) - rkey := tid.TID() + star := models.Star{ + Did: currentUser.Did, + Rkey: tid.TID(), + SubjectType: subjectType, + Subject: subjectKey, + Created: time.Now(), + } - starRecord := &tangled.FeedStar{ - CreatedAt: createdAt, - Subject: starSubject, + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + l.Error("failed to start transaction", "err", err) + return + } + defer tx.Rollback() + + if err := db.UpsertStar(tx, star); err != nil { + l.Error("failed to star", "err", err) + return } resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.FeedStarNSID, Repo: currentUser.Did, - Rkey: rkey, - Record: &lexutil.LexiconTypeDecoder{Val: starRecord}, + Rkey: star.Rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.FeedStar{ + CreatedAt: star.Created.Format(time.RFC3339), + Subject: starSubject, + }, + }, }) if err != nil { l.Error("failed to create atproto record", "err", err) @@ -97,25 +114,17 @@ } l.Info("created atproto record", "uri", resp.Uri) - star := &models.Star{ - Did: currentUser.Did, - SubjectType: subjectType, - Subject: subjectKey, - Rkey: rkey, + if err := tx.Commit(); err != nil { + l.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } - err = db.AddStar(s.db, star) - if err != nil { - l.Error("failed to star", "err", err) - return - } + s.notifier.NewStar(r.Context(), &star) starCount, err := db.GetStarCount(s.db, subjectType, subjectKey) if err != nil { l.Error("failed to get star count", "subject", subjectKey, "err", err) } - - s.notifier.NewStar(r.Context(), star) s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ IsStarred: true, @@ -162,9 +171,9 @@ } s.notifier.DeleteStar(r.Context(), &models.Star{ - Did: currentUser.Did, + Did: currentUser.Did, SubjectType: subjectType, - Subject: subjectKey, + Subject: subjectKey, // Rkey // Created }) -- tangled.sh