Files
team/server/channels/store/sqlstore/reaction_store.go
916f6b7225 MM-68332: consistently enforce query timeouts (#36522)
* Remove QueryRowx (no-timeout) from sqlxDBWrapper; migrate caller to QueryRowX

QueryRowx forwarded to context.Background() with no timeout, while QueryRowX
(uppercase) already enforces the wrapper timeout. Removing the no-timeout
variant eliminates an accidental footgun and migrates the one sqlstore caller
(plugin_store) to the timeout-enforcing method.

* sqlxRow, with timeout cancel after Scan

Introduce sqlxRow, which pairs *sqlx.Row with its context cancel function.
QueryRowX (on both sqlxDBWrapper and sqlxTxWrapper) now returns *sqlxRow;
Scan calls cancel immediately after the row is consumed, releasing the
timeout context as soon as possible rather than waiting for the timer.

* sqlxRows, with timeout cancel on Close

Introduce sqlxRows, which embeds *sqlx.Rows and holds the timeout context's
cancel function. Close() cancels the context immediately after the rows are
done, releasing timeout resources as soon as iteration completes rather than
waiting for the timer to fire.

Introduce rowScanner interface (Next/Scan/Err) so the internal helpers
scanRowsIntoMap and scanRetentionIdsForDeletion accept any row iterator
rather than the concrete *sql.Rows, accommodating the new return types
without threading *sqlxRows through every caller.

* abolish the X suffix altogether

* delete unused NamedQuery

* add timeout tests for Query and QueryRow on db and tx wrappers

* fix tx/timeout tests: handle pq driver.ErrBadConn on killed connection

* fixup! fix tx/timeout tests: handle pq driver.ErrBadConn on killed connection

* override Next() on sqlxRows to cancel on EOF

* rm redundant row.Err(), handled by Scan

* apply timeout to context unless deadline set

* rebind consistently

* address review feedback: rename, trace, and QueryContext fix

- Rename withQueryTimeout → ensureQueryTimeout to better convey that it
  respects existing deadlines rather than overriding them.
- Add missing w.trace blocks to QueryRowContext and ExecContext.
- Change QueryContext to use ensureQueryTimeout and return *sqlxRows
  (instead of *sql.Rows) so the cancel is deferred to Close/Next
  rather than released prematurely.

* fix Beginx → Begin after master merge

* fix golangci-lint inline warning in sqlx_wrapper_test

---------

Co-authored-by: Mattermost Build <[email protected]>
2026-05-15 13:02:13 +00:00

425 lines
12 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"time"
sq "github.com/mattermost/squirrel"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/pkg/errors"
)
type SqlReactionStore struct {
*SqlStore
}
func newSqlReactionStore(sqlStore *SqlStore) store.ReactionStore {
return &SqlReactionStore{sqlStore}
}
func (s *SqlReactionStore) Save(reaction *model.Reaction) (re *model.Reaction, err error) {
reaction.PreSave()
if err := reaction.IsValid(); err != nil {
return nil, err
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
if reaction.ChannelId == "" {
// get channelId, if not already populated
var channelIds []string
query := "SELECT ChannelId from Posts where Id = ?"
err = transaction.Select(&channelIds, query, reaction.PostId)
if err != nil {
return nil, errors.Wrap(err, "failed while getting channelId from Posts")
}
if len(channelIds) == 0 {
return nil, store.NewErrNotFound("Post", reaction.PostId)
}
reaction.ChannelId = channelIds[0]
}
err = s.saveReactionAndUpdatePost(transaction, reaction)
if err != nil {
// We don't consider duplicated save calls as an error
if !IsUniqueConstraintError(err, []string{"reactions_pkey", "PRIMARY"}) {
return nil, errors.Wrap(err, "failed while saving reaction or updating post")
}
} else {
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
}
return reaction, nil
}
func (s *SqlReactionStore) Delete(reaction *model.Reaction) (re *model.Reaction, err error) {
reaction.PreUpdate()
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
if err := deleteReactionAndUpdatePost(transaction, reaction); err != nil {
return nil, errors.Wrap(err, "deleteReactionAndUpdatePost")
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return reaction, nil
}
// GetForPost returns all reactions associated with `postId` that are not deleted.
func (s *SqlReactionStore) GetForPost(postId string, allowFromCache bool) ([]*model.Reaction, error) {
builder := s.getQueryBuilder().
Select("UserId", "PostId", "EmojiName", "CreateAt", "COALESCE(UpdateAt, CreateAt) As UpdateAt",
"COALESCE(DeleteAt, 0) As DeleteAt", "RemoteId", "ChannelId").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0}).
OrderBy("CreateAt")
var reactions []*model.Reaction
if err := s.GetReplica().SelectBuilder(&reactions, builder); err != nil {
return nil, errors.Wrapf(err, "failed to get Reactions with postId=%s", postId)
}
return reactions, nil
}
func (s *SqlReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) {
query := s.getQueryBuilder().
Select("1").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Eq{"EmojiName": emojiName}).
Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0})
var hasRows bool
if err := s.GetReplica().GetBuilder(&hasRows, query); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, errors.Wrap(err, "failed to check for existing reaction")
}
return hasRows, nil
}
// GetForPostSince returns all reactions associated with `postId` updated after `since`.
func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) {
query := s.getQueryBuilder().
Select("UserId", "PostId", "EmojiName", "CreateAt", "COALESCE(UpdateAt, CreateAt) As UpdateAt",
"COALESCE(DeleteAt, 0) As DeleteAt", "RemoteId").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Gt{"UpdateAt": since}).
OrderBy("CreateAt")
if excludeRemoteId != "" {
query = query.Where(sq.NotEq{"COALESCE(RemoteId, '')": excludeRemoteId})
}
if !inclDeleted {
query = query.Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0})
}
var reactions []*model.Reaction
if err := s.GetReplica().SelectBuilder(&reactions, query); err != nil {
return nil, errors.Wrapf(err, "failed to find reactions")
}
return reactions, nil
}
func (s *SqlReactionStore) GetUniqueCountForPost(postId string) (int, error) {
query := s.getQueryBuilder().
Select("COUNT(DISTINCT EmojiName)").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Eq{"DeleteAt": 0})
var count int64
err := s.GetReplica().GetBuilder(&count, query)
if err != nil {
return 0, errors.Wrap(err, "failed to count Reactions")
}
return int(count), nil
}
func (s *SqlReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) {
placeholder, values := constructArrayArgs(postIds)
var reactions []*model.Reaction
if err := s.GetReplica().Select(&reactions,
`SELECT
UserId,
PostId,
EmojiName,
CreateAt,
COALESCE(UpdateAt, CreateAt) As UpdateAt,
COALESCE(DeleteAt, 0) As DeleteAt,
RemoteId,
ChannelId
FROM
Reactions
WHERE
PostId IN `+placeholder+` AND COALESCE(DeleteAt, 0) = 0
ORDER BY
CreateAt`, values...); err != nil {
return nil, errors.Wrap(err, "failed to get Reactions")
}
return reactions, nil
}
func (s *SqlReactionStore) GetSingle(userID, postID, remoteID, emojiName string) (*model.Reaction, error) {
query := s.getQueryBuilder().
Select("UserId", "PostId", "EmojiName", "CreateAt",
"COALESCE(UpdateAt, CreateAt) As UpdateAt", "COALESCE(DeleteAt, 0) As DeleteAt",
"RemoteId", "ChannelId").
From("Reactions").
Where(sq.Eq{"UserId": userID}).
Where(sq.Eq{"PostId": postID}).
Where(sq.Eq{"COALESCE(RemoteId, '')": remoteID}).
Where(sq.Eq{"EmojiName": emojiName})
var reactions []*model.Reaction
if err := s.GetReplica().SelectBuilder(&reactions, query); err != nil {
return nil, errors.Wrapf(err, "failed to find reaction")
}
if len(reactions) == 0 {
return nil, store.NewErrNotFound("Reaction", fmt.Sprintf("user_id=%s, post_id=%s, remote_id=%s, emoji_name=%s",
userID, postID, remoteID, emojiName))
}
return reactions[0], nil
}
func (s *SqlReactionStore) DeleteAllWithEmojiName(emojiName string) error {
var reactions []*model.Reaction
now := model.GetMillis()
if err := s.GetReplica().Select(&reactions,
`SELECT
UserId,
PostId,
EmojiName,
CreateAt,
COALESCE(UpdateAt, CreateAt) As UpdateAt,
COALESCE(DeleteAt, 0) As DeleteAt,
RemoteId
FROM
Reactions
WHERE
EmojiName = ? AND COALESCE(DeleteAt, 0) = 0`, emojiName); err != nil {
return errors.Wrapf(err, "failed to get Reactions with emojiName=%s", emojiName)
}
_, err := s.GetMaster().Exec(
`UPDATE
Reactions
SET
UpdateAt = ?, DeleteAt = ?
WHERE
EmojiName = ? AND COALESCE(DeleteAt, 0) = 0`, now, now, emojiName)
if err != nil {
return errors.Wrapf(err, "failed to delete Reactions with emojiName=%s", emojiName)
}
for _, reaction := range reactions {
_, err := s.GetMaster().Exec(UpdatePostHasReactionsOnDeleteQuery, now, reaction.PostId, reaction.PostId)
if err != nil {
mlog.Warn("Unable to update Post.HasReactions while removing reactions",
mlog.String("post_id", reaction.PostId),
mlog.Err(err))
}
}
return nil
}
func (s *SqlReactionStore) permanentDeleteReactions(userId string) ([]string, error) {
txn, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
defer finalizeTransactionX(txn, &err)
postIds := []string{}
err = txn.Select(&postIds, "SELECT PostId FROM Reactions WHERE UserId = ?", userId)
if err != nil {
return nil, errors.Wrapf(err, "failed to get Reactions with userId=%s", userId)
}
query := s.getQueryBuilder().
Delete("Reactions").
Where(sq.And{
sq.Eq{"PostId": postIds},
sq.Eq{"UserId": userId},
})
_, err = txn.ExecBuilder(query)
if err != nil {
return nil, errors.Wrapf(err, "failed to delete reactions with userId=%s", userId)
}
if err = txn.Commit(); err != nil {
return nil, err
}
return postIds, nil
}
func (s SqlReactionStore) PermanentDeleteByUser(userId string) error {
now := model.GetMillis()
postIds, err := s.permanentDeleteReactions(userId)
if err != nil {
return err
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return err
}
defer finalizeTransactionX(transaction, &err)
for _, postId := range postIds {
_, err = transaction.Exec(UpdatePostHasReactionsOnDeleteQuery, now, postId, postId)
if err != nil {
mlog.Warn("Unable to update Post.HasReactions while removing reactions",
mlog.String("post_id", postId),
mlog.Err(err))
}
time.Sleep(10 * time.Millisecond)
}
if err = transaction.Commit(); err != nil {
return err
}
return nil
}
func (s *SqlReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) (int64, error) {
txn, err := s.GetMaster().Begin()
if err != nil {
return 0, err
}
defer finalizeTransactionX(txn, &err)
query := s.getQueryBuilder().
Delete("Reactions").
Where(
sq.Eq{"PostId": r.Ids},
)
sqlResult, err := txn.ExecBuilder(query)
if err != nil {
return 0, errors.Wrapf(err, "failed to delete orphaned reactions with RetentionIdsForDeletion Id=%s", r.Id)
}
err = deleteFromRetentionIdsTx(txn, r.Id)
if err != nil {
return 0, err
}
if err = txn.Commit(); err != nil {
return 0, err
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "unable to retrieve rows affected")
}
return rowsAffected, nil
}
func (s *SqlReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
query := "DELETE from Reactions WHERE CreateAt = any (array (SELECT CreateAt FROM Reactions WHERE CreateAt < ? LIMIT ?))"
sqlResult, err := s.GetMaster().Exec(query, endTime, limit)
if err != nil {
return 0, errors.Wrap(err, "failed to delete Reactions")
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "unable to get rows affected for deleted Reactions")
}
return rowsAffected, nil
}
func (s *SqlReactionStore) saveReactionAndUpdatePost(transaction *sqlxTxWrapper, reaction *model.Reaction) error {
reaction.DeleteAt = 0
if _, err := transaction.NamedExec(
`INSERT INTO
Reactions
(UserId, PostId, EmojiName, CreateAt, UpdateAt, DeleteAt, RemoteId, ChannelId)
VALUES
(:UserId, :PostId, :EmojiName, :CreateAt, :UpdateAt, :DeleteAt, :RemoteId, :ChannelId)
ON CONFLICT (UserId, PostId, EmojiName)
DO UPDATE SET UpdateAt = :UpdateAt, DeleteAt = :DeleteAt, RemoteId = :RemoteId, ChannelId = :ChannelId`, reaction); err != nil {
return err
}
return updatePostForReactionsOnInsert(transaction, reaction.PostId)
}
func deleteReactionAndUpdatePost(transaction *sqlxTxWrapper, reaction *model.Reaction) error {
if _, err := transaction.Exec(
`UPDATE
Reactions
SET
UpdateAt = ?, DeleteAt = ?, RemoteId = ?
WHERE
PostId = ? AND
UserId = ? AND
EmojiName = ?`, reaction.UpdateAt, reaction.UpdateAt, reaction.RemoteId, reaction.PostId, reaction.UserId, reaction.EmojiName); err != nil {
return err
}
return updatePostForReactionsOnDelete(transaction, reaction.PostId)
}
const (
UpdatePostHasReactionsOnDeleteQuery = `UPDATE
Posts
SET
UpdateAt = ?,
HasReactions = (SELECT count(0) > 0 FROM Reactions WHERE PostId = ? AND COALESCE(DeleteAt, 0) = 0)
WHERE
Id = ?`
)
func updatePostForReactionsOnDelete(transaction *sqlxTxWrapper, postId string) error {
updateAt := model.GetMillis()
_, err := transaction.Exec(UpdatePostHasReactionsOnDeleteQuery, updateAt, postId, postId)
return err
}
func updatePostForReactionsOnInsert(transaction *sqlxTxWrapper, postId string) error {
_, err := transaction.Exec(
`UPDATE
Posts
SET
HasReactions = True,
UpdateAt = ?
WHERE
Id = ?`,
model.GetMillis(),
postId,
)
return err
}