For some queries MySQL performed poorly until we slightly
tune the query. Here are the results:
Old query
```
// 5.7
mysql> explain SELECT Posts.Id FROM Posts LEFT JOIN Channels ON Posts.ChannelId = Channels.Id WHERE Channels.Id IS NULL LIMIT 3000;
+----+-------------+----------+------------+--------+---------------+--------------------------------+---------+----------------------------+---------+----------+--------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+--------+---------------+--------------------------------+---------+----------------------------+---------+----------+--------------------------------------+
| 1 | SIMPLE | Posts | NULL | index | NULL | idx_posts_channel_id_update_at | 116 | NULL | 6439997 | 100.00 | Using index |
| 1 | SIMPLE | Channels | NULL | eq_ref | PRIMARY | PRIMARY | 106 | mattermost.Posts.ChannelId | 1 | 100.00 | Using where; Not exists; Using index |
+----+-------------+----------+------------+--------+---------------+--------------------------------+---------+----------------------------+---------+----------+--------------------------------------+
2 rows in set, 1 warning (0.16 sec)
// 8.0
| -> Limit: 3000 row(s) (cost=13321292.71 rows=3000) (actual time=14291.753..14291.753 rows=0 loops=1)
-> Filter: (Channels.Id is null) (cost=13321292.71 rows=10895918) (actual time=14291.751..14291.751 rows=0 loops=1)
-> Nested loop antijoin (cost=13321292.71 rows=10895918) (actual time=14291.749..14291.749 rows=0 loops=1)
-> Covering index scan on Posts using idx_posts_channel_id_update_at (cost=1337847.05 rows=10895918) (actual time=6.258..10928.120 rows=11824842 loops=1)
-> Single-row covering index lookup on Channels using PRIMARY (Id=Posts.ChannelId) (cost=1.00 rows=1) (actual time=0.000..0.000 rows=1 loops=11824842)
```
New query
```
// 5.7
mysql> EXPLAIN SELECT Posts.Id FROM Posts WHERE Posts.ChannelId NOT IN (SELECT Id FROM Channels) LIMIT 3000;
+----+-------------+----------+------------+-------+---------------+--------------------------------+---------+------+---------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+-------+---------------+--------------------------------+---------+------+---------+----------+--------------------------+
| 1 | PRIMARY | Posts | NULL | index | NULL | idx_posts_channel_id_update_at | 116 | NULL | 6440001 | 100.00 | Using where; Using index |
| 2 | SUBQUERY | Channels | NULL | index | PRIMARY | idx_channels_update_at | 9 | NULL | 60964 | 100.00 | Using index |
+----+-------------+----------+------------+-------+---------------+--------------------------------+---------+------+---------+----------+--------------------------+
2 rows in set, 1 warning (0.17 sec)
// 8.0
| -> Limit: 3000 row(s) (cost=1337847.05 rows=3000) (actual time=8309.610..8309.610 rows=0 loops=1)
-> Filter: <in_optimizer>(Posts.ChannelId,Posts.ChannelId in (select #2) is false) (cost=1337847.05 rows=10895918) (actual time=8309.609..8309.609 rows=0 loops=1)
-> Covering index scan on Posts using idx_posts_channel_id_update_at (cost=1337847.05 rows=10895918) (actual time=0.127..2938.252 rows=11824842 loops=1)
-> Select #2 (subquery in condition; run only once)
-> Filter: ((Posts.ChannelId = `<materialized_subquery>`.Id)) (cost=57224.17..57224.17 rows=1) (actual time=0.014..0.014 rows=1 loops=136308)
-> Limit: 1 row(s) (cost=57224.07..57224.07 rows=1) (actual time=0.013..0.013 rows=1 loops=136308)
-> Index lookup on <materialized_subquery> using <auto_distinct_key> (Id=Posts.ChannelId) (actual time=0.013..0.013 rows=1 loops=136308)
-> Materialize with deduplication (cost=57224.07..57224.07 rows=266638) (actual time=1361.488..1361.488 rows=270959 loops=1)
-> Covering index scan on Channels using idx_channels_update_at (cost=30560.27 rows=266638) (actual time=1.807..264.116 rows=270959 loops=1)
```
We can see that in the base case, MySQL does an anitjoin which is actually fine in most queries
but in this query we are selecting posts.Id, but joining on posts.channelid. In such a case, if we use a nested query,
suddenly MySQL can materialize the sub-query and do only a covering index scan on Posts. Also compare the costs.
Old one has 13321292.71 whereas new one has 1337847.05. So an order of magnitude improvement.
Old query
```
Limit (cost=6561.27..655145.99 rows=1 width=27) (actual time=3897.766..3902.683 rows=0 loops=1)
-> Gather (cost=6561.27..655145.99 rows=1 width=27) (actual time=3702.143..3707.058 rows=0 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Hash Anti Join (cost=5561.27..654145.89 rows=1 width=27) (actual time=3631.030..3631.032 rows=0 loops=3)
Hash Cond: ((posts.channelid)::text = (channels.id)::text)
-> Parallel Seq Scan on posts (cost=0.00..627918.55 rows=5510955 width=54) (actual time=0.719..2701.470 rows=4410848 loops=3)
-> Parallel Hash (cost=4874.45..4874.45 rows=54945 width=27) (actual time=193.211..193.212 rows=43956 loops=3)
Buckets: 262144 Batches: 1 Memory Usage: 10368kB
-> Parallel Seq Scan on channels (cost=0.00..4874.45 rows=54945 width=27) (actual time=105.575..146.071 rows=43956 loops=3)
Planning Time: 41.365 ms
JIT:
Functions: 31
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 11.106 ms, Inlining 237.381 ms, Optimization 169.412 ms, Emission 103.889 ms, Total 521.787 ms
Execution Time: 4297.552 ms
```
New query
```
Limit (cost=1000.00..9419975.49 rows=3000 width=27)
-> Gather (cost=1000.00..20761674119.53 rows=6612717 width=27)
Workers Planned: 2
-> Parallel Seq Scan on posts (cost=0.00..20761011847.83 rows=2755299 width=27)
Filter: (NOT (SubPlan 1))
SubPlan 1
-> Materialize (cost=0.00..7205.04 rows=131869 width=27)
-> Seq Scan on channels (cost=0.00..5643.69 rows=131869 width=27)
JIT:
Functions: 9
Options: Inlining true, Optimization true, Expressions true, Deforming true
(11 rows)
```
Note: this was so bad, I couldn't even wait to finish EXPLAIN ANALYZE as it was taking more than
10 minutes to finish.
Old query
```
EXPLAIN ANALYZE SELECT Threads.PostId FROM Threads LEFT JOIN Channels ON Threads.ChannelId = Channels.Id WHERE Channels.Id IS NULL LIMIT 3000;
// 8.0
------------------+
| -> Limit: 3000 row(s) (cost=727387.95 rows=3000) (actual time=725.905..725.905 rows=0 loops=1)
-> Filter: (Channels.Id is null) (cost=727387.95 rows=807045) (actual time=725.903..725.903 rows=0 loops=1)
-> Nested loop antijoin (cost=727387.95 rows=807045) (actual time=725.901..725.901 rows=0 loops=1)
-> Covering index scan on Threads using idx_threads_channel_id_last_reply_at (cost=92668.15 rows=807045) (actual time=0.201..214.332 rows=846313 loops=1)
-> Single-row covering index lookup on Channels using PRIMARY (Id=Threads.ChannelId) (cost=0.69 rows=1) (actual time=0.001..0.001 rows=1 loops=846313)
|
```
New query
```
// 8.0
mysql> EXPLAIN ANALYZE SELECT Threads.PostId FROM Threads WHERE Threads.ChannelId NOT IN (SELECT Id FROM Channels) LIMIT 3000;
-> Limit: 3000 row(s) (cost=92668.15 rows=3000) (actual time=1770.798..1770.798 rows=0 loops=1)
-> Filter: <in_optimizer>(Threads.ChannelId,Threads.ChannelId in (select #2) is false) (cost=92668.15 rows=807045) (actual time=1770.796..1770.796 rows=0 loops=1)
-> Covering index scan on Threads using idx_threads_channel_id_last_reply_at (cost=92668.15 rows=807045) (actual time=0.191..197.768 rows=846313 loops=1)
-> Select #2 (subquery in condition; run only once)
-> Filter: ((Threads.ChannelId = `<materialized_subquery>`.Id)) (cost=56081.83..56081.83 rows=1) (actual time=0.011..0.011 rows=1 loops=114202)
-> Limit: 1 row(s) (cost=56081.73..56081.73 rows=1) (actual time=0.011..0.011 rows=1 loops=114202)
-> Index lookup on <materialized_subquery> using <auto_distinct_key> (Id=Threads.ChannelId) (actual time=0.011..0.011 rows=1 loops=114202)
-> Materialize with deduplication (cost=56081.73..56081.73 rows=266638) (actual time=901.592..901.592 rows=270959 loops=1)
-> Covering index scan on Channels using idx_channels_update_at (cost=29417.93 rows=266638) (actual time=0.129..57.806 rows=270959 loops=1)
|
```
I did not test this query in Community but I expect the same logic to happen. Again, notice
the improvement in cost. 727387.95 vs 727387.95
Old query
```
EXPLAIN ANALYZE SELECT Threads.PostId FROM Threads LEFT JOIN Channels ON Threads.ChannelId = Channels.Id WHERE Channels.Id IS NULL LIMIT 3000;
Limit (cost=6561.27..74898.36 rows=1 width=27) (actual time=628.141..630.437 rows=0 loops=1)
-> Gather (cost=6561.27..74898.36 rows=1 width=27) (actual time=628.138..630.433 rows=0 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Hash Anti Join (cost=5561.27..73898.26 rows=1 width=27) (actual time=612.672..612.674 rows=0 loops=3)
Hash Cond: ((threads.channelid)::text = (channels.id)::text)
-> Parallel Seq Scan on threads (cost=0.00..66852.00 rows=396000 width=54) (actual time=0.308..505.021 rows=315151 loops=3)
-> Parallel Hash (cost=4874.45..4874.45 rows=54945 width=27) (actual time=21.031..21.031 rows=43956 loops=3)
Buckets: 262144 Batches: 1 Memory Usage: 10336kB
-> Parallel Seq Scan on channels (cost=0.00..4874.45 rows=54945 width=27) (actual time=0.018..7.959 rows=43956 loops=3)
```
New query
```
[bigdb] # EXPLAIN SELECT Threads.PostId FROM Threads WHERE Threads.ChannelId NOT IN (SELECT Id FROM Channels) LIMIT 3000;
QUERY PLAN
-------------------------------------------------------------------------------------------
Limit (cost=1000.00..9420102.76 rows=3000 width=27)
-> Gather (cost=1000.00..1491986877.26 rows=475200 width=27)
Workers Planned: 2
-> Parallel Seq Scan on threads (cost=0.00..1491938357.26 rows=198000 width=27)
Filter: (NOT (SubPlan 1))
SubPlan 1
-> Materialize (cost=0.00..7205.04 rows=131869 width=27)
-> Seq Scan on channels (cost=0.00..5643.69 rows=131869 width=27)
JIT:
Functions: 9
Options: Inlining true, Optimization true, Expressions true, Deforming true
```
As expected, Postgres behaves correctly in the original query.
https://mattermost.atlassian.net/browse/MM-50435
```release-note
NONE
```
1186 строки
36 KiB
Go
1186 строки
36 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package sqlstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strconv"
|
|
"time"
|
|
|
|
sq "github.com/mattermost/squirrel"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/mattermost/mattermost-server/v6/model"
|
|
"github.com/mattermost/mattermost-server/v6/store"
|
|
"github.com/mattermost/mattermost-server/v6/utils"
|
|
)
|
|
|
|
// JoinedThread allows querying the Threads + Posts table in a single query, before looking up
|
|
// users and unpacking into a model.ThreadResponse.
|
|
type JoinedThread struct {
|
|
PostId string
|
|
ReplyCount int64
|
|
LastReplyAt int64
|
|
LastViewedAt int64
|
|
UnreadReplies int64
|
|
UnreadMentions int64
|
|
Participants model.StringArray
|
|
ThreadDeleteAt int64
|
|
TeamId string
|
|
IsUrgent bool
|
|
model.Post
|
|
}
|
|
|
|
func (thread *JoinedThread) toThreadResponse(users map[string]*model.User) *model.ThreadResponse {
|
|
threadParticipants := make([]*model.User, 0, len(thread.Participants))
|
|
for _, participantUserId := range thread.Participants {
|
|
if participant, ok := users[participantUserId]; ok {
|
|
threadParticipants = append(threadParticipants, participant)
|
|
}
|
|
}
|
|
|
|
return &model.ThreadResponse{
|
|
PostId: thread.PostId,
|
|
ReplyCount: thread.ReplyCount,
|
|
LastReplyAt: thread.LastReplyAt,
|
|
LastViewedAt: thread.LastViewedAt,
|
|
UnreadReplies: thread.UnreadReplies,
|
|
UnreadMentions: thread.UnreadMentions,
|
|
Participants: threadParticipants,
|
|
Post: thread.Post.ToNilIfInvalid(),
|
|
DeleteAt: thread.ThreadDeleteAt,
|
|
IsUrgent: thread.IsUrgent,
|
|
}
|
|
}
|
|
|
|
type SqlThreadStore struct {
|
|
*SqlStore
|
|
|
|
// threadsSelectQuery is for querying directly into model.Thread
|
|
threadsSelectQuery sq.SelectBuilder
|
|
|
|
// threadsAndPostsSelectQuery is for querying into a struct embedding fields from
|
|
// model.Thread and model.Post.
|
|
threadsAndPostsSelectQuery sq.SelectBuilder
|
|
}
|
|
|
|
func (s *SqlThreadStore) ClearCaches() {
|
|
}
|
|
|
|
func newSqlThreadStore(sqlStore *SqlStore) store.ThreadStore {
|
|
s := SqlThreadStore{
|
|
SqlStore: sqlStore,
|
|
}
|
|
|
|
s.initializeQueries()
|
|
|
|
return &s
|
|
}
|
|
|
|
func (s *SqlThreadStore) initializeQueries() {
|
|
s.threadsSelectQuery = s.getQueryBuilder().
|
|
Select(
|
|
"Threads.PostId",
|
|
"Threads.ChannelId",
|
|
"Threads.ReplyCount",
|
|
"Threads.LastReplyAt",
|
|
"Threads.Participants",
|
|
"COALESCE(Threads.ThreadDeleteAt, 0) AS DeleteAt",
|
|
"COALESCE(Threads.ThreadTeamId, '') AS TeamId",
|
|
).
|
|
From("Threads")
|
|
|
|
s.threadsAndPostsSelectQuery = s.getQueryBuilder().
|
|
Select(
|
|
"Threads.PostId",
|
|
"Threads.ChannelId",
|
|
"Threads.ReplyCount",
|
|
"Threads.LastReplyAt",
|
|
"Threads.Participants",
|
|
"COALESCE(Threads.ThreadDeleteAt, 0) AS ThreadDeleteAt",
|
|
"COALESCE(Threads.ThreadTeamId, '') AS TeamId",
|
|
).
|
|
From("Threads")
|
|
}
|
|
|
|
func (s *SqlThreadStore) Get(id string) (*model.Thread, error) {
|
|
var thread model.Thread
|
|
|
|
query := s.threadsSelectQuery.
|
|
Where(sq.Eq{"PostId": id})
|
|
|
|
err := s.GetReplicaX().GetBuilder(&thread, query)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, errors.Wrapf(err, "failed to get thread with id=%s", id)
|
|
}
|
|
return &thread, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) getTotalThreadsQuery(userId, teamId string, opts model.GetUserThreadsOpts) sq.SelectBuilder {
|
|
query := s.getQueryBuilder().
|
|
Select("COUNT(ThreadMemberships.PostId)").
|
|
From("ThreadMemberships").
|
|
LeftJoin("Threads ON Threads.PostId = ThreadMemberships.PostId").
|
|
Where(sq.Eq{
|
|
"ThreadMemberships.UserId": userId,
|
|
"ThreadMemberships.Following": true,
|
|
})
|
|
|
|
if teamId != "" {
|
|
query = query.
|
|
Where(sq.Or{
|
|
sq.Eq{"Threads.ThreadTeamId": teamId},
|
|
sq.Eq{"Threads.ThreadTeamId": ""},
|
|
})
|
|
}
|
|
|
|
if !opts.Deleted {
|
|
query = query.Where(sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0})
|
|
}
|
|
|
|
return query
|
|
}
|
|
|
|
// GetTotalUnreadThreads counts the number of unread threads for the given user, optionally
|
|
// constrained to the given team + DMs/GMs.
|
|
func (s *SqlThreadStore) GetTotalUnreadThreads(userId, teamId string, opts model.GetUserThreadsOpts) (int64, error) {
|
|
query := s.getTotalThreadsQuery(userId, teamId, opts).
|
|
Where(sq.Expr("ThreadMemberships.LastViewed < Threads.LastReplyAt"))
|
|
|
|
var totalUnreadThreads int64
|
|
err := s.GetReplicaX().GetBuilder(&totalUnreadThreads, query)
|
|
if err != nil {
|
|
return 0, errors.Wrapf(err, "failed to count unread threads for user id=%s", userId)
|
|
}
|
|
|
|
return totalUnreadThreads, nil
|
|
}
|
|
|
|
// GetTotalUnreadThreads counts the number of threads for the given user, optionally constrained
|
|
// to the given team + DMs/GMs.
|
|
func (s *SqlThreadStore) GetTotalThreads(userId, teamId string, opts model.GetUserThreadsOpts) (int64, error) {
|
|
if opts.Unread {
|
|
return 0, errors.New("GetTotalThreads does not support the Unread flag; use GetTotalUnreadThreads instead")
|
|
}
|
|
|
|
query := s.getTotalThreadsQuery(userId, teamId, opts)
|
|
|
|
var totalThreads int64
|
|
err := s.GetReplicaX().GetBuilder(&totalThreads, query)
|
|
if err != nil {
|
|
return 0, errors.Wrapf(err, "failed to count threads for user id=%s", userId)
|
|
}
|
|
|
|
return totalThreads, nil
|
|
}
|
|
|
|
// GetTotalUnreadMentions counts the number of unread mentions for the given user, optionally
|
|
// constrained to the given team + DMs/GMs.
|
|
func (s *SqlThreadStore) GetTotalUnreadMentions(userId, teamId string, opts model.GetUserThreadsOpts) (int64, error) {
|
|
var totalUnreadMentions int64
|
|
|
|
query := s.getQueryBuilder().
|
|
Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0)").
|
|
From("ThreadMemberships").
|
|
LeftJoin("Threads ON Threads.PostId = ThreadMemberships.PostId").
|
|
Where(sq.Eq{
|
|
"ThreadMemberships.UserId": userId,
|
|
"ThreadMemberships.Following": true,
|
|
})
|
|
|
|
if teamId != "" {
|
|
query = query.
|
|
Where(sq.Or{
|
|
sq.Eq{"Threads.ThreadTeamId": teamId},
|
|
sq.Eq{"Threads.ThreadTeamId": ""},
|
|
})
|
|
}
|
|
|
|
if !opts.Deleted {
|
|
query = query.Where(sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0})
|
|
}
|
|
|
|
err := s.GetReplicaX().GetBuilder(&totalUnreadMentions, query)
|
|
if err != nil {
|
|
return 0, errors.Wrapf(err, "failed to count unread mentions for user id=%s", userId)
|
|
}
|
|
|
|
return totalUnreadMentions, nil
|
|
}
|
|
|
|
// GetTotalUnreadUrgentMentions counts the number of unread mentions for the given user, optionally
|
|
// constrained to the given team + DMs/GMs.
|
|
func (s *SqlThreadStore) GetTotalUnreadUrgentMentions(userId, teamId string, opts model.GetUserThreadsOpts) (int64, error) {
|
|
var totalUnreadUrgentMentions int64
|
|
|
|
query := s.getQueryBuilder().
|
|
Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0)").
|
|
From("ThreadMemberships").
|
|
Join("PostsPriority ON PostsPriority.PostId = ThreadMemberships.PostId").
|
|
Where(sq.Eq{
|
|
"ThreadMemberships.UserId": userId,
|
|
"ThreadMemberships.Following": true,
|
|
"PostsPriority.Priority": model.PostPriorityUrgent,
|
|
})
|
|
|
|
if teamId != "" || !opts.Deleted {
|
|
query = query.Join("Threads ON Threads.PostId = ThreadMemberships.PostId")
|
|
}
|
|
|
|
if teamId != "" {
|
|
query = query.
|
|
Where(sq.Or{
|
|
sq.Eq{"Threads.ThreadTeamId": teamId},
|
|
sq.Eq{"Threads.ThreadTeamId": ""},
|
|
})
|
|
}
|
|
|
|
if !opts.Deleted {
|
|
query = query.
|
|
Where(sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0})
|
|
}
|
|
|
|
err := s.GetReplicaX().GetBuilder(&totalUnreadUrgentMentions, query)
|
|
if err != nil {
|
|
return 0, errors.Wrapf(err, "failed to count unread urgent mentions for user id=%s", userId)
|
|
}
|
|
|
|
return totalUnreadUrgentMentions, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.GetUserThreadsOpts) ([]*model.ThreadResponse, error) {
|
|
pageSize := uint64(30)
|
|
if opts.PageSize != 0 {
|
|
pageSize = opts.PageSize
|
|
}
|
|
|
|
unreadRepliesQuery := sq.
|
|
Select("COUNT(Posts.Id)").
|
|
From("Posts").
|
|
Where(sq.Expr("Posts.RootId = ThreadMemberships.PostId")).
|
|
Where(sq.Expr("Posts.CreateAt > ThreadMemberships.LastViewed"))
|
|
|
|
if !opts.Deleted {
|
|
unreadRepliesQuery = unreadRepliesQuery.Where(sq.Eq{"Posts.DeleteAt": 0})
|
|
}
|
|
|
|
query := s.threadsAndPostsSelectQuery.
|
|
Column(postSliceCoalesceQuery()).
|
|
Columns(
|
|
"ThreadMemberships.LastViewed as LastViewedAt",
|
|
"ThreadMemberships.UnreadMentions as UnreadMentions",
|
|
).
|
|
Column(sq.Alias(unreadRepliesQuery, "UnreadReplies")).
|
|
Join("Posts ON Posts.Id = Threads.PostId").
|
|
Join("ThreadMemberships ON ThreadMemberships.PostId = Threads.PostId")
|
|
|
|
query = query.
|
|
Where(sq.Eq{"ThreadMemberships.UserId": userId}).
|
|
Where(sq.Eq{"ThreadMemberships.Following": true})
|
|
|
|
if opts.IncludeIsUrgent {
|
|
urgencyCase := sq.
|
|
Case().
|
|
When(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, "true").
|
|
Else("false")
|
|
|
|
query = query.
|
|
Column(sq.Alias(urgencyCase, "IsUrgent")).
|
|
LeftJoin("PostsPriority ON PostsPriority.PostId = Threads.PostId")
|
|
}
|
|
|
|
// If a team is specified, constrain to channels in that team or DMs/GMs without
|
|
// a team at all.
|
|
if teamId != "" {
|
|
query = query.
|
|
Where(sq.Or{
|
|
sq.Eq{"Threads.ThreadTeamId": teamId},
|
|
sq.Eq{"Threads.ThreadTeamId": ""},
|
|
})
|
|
}
|
|
|
|
if !opts.Deleted {
|
|
query = query.Where(sq.Or{
|
|
sq.Eq{"Threads.ThreadDeleteAt": nil},
|
|
sq.Eq{"Threads.ThreadDeleteAt": 0},
|
|
})
|
|
}
|
|
|
|
if opts.Since > 0 {
|
|
query = query.
|
|
Where(sq.Or{
|
|
sq.GtOrEq{"ThreadMemberships.LastUpdated": opts.Since},
|
|
sq.GtOrEq{"Threads.LastReplyAt": opts.Since},
|
|
})
|
|
}
|
|
|
|
if opts.Unread {
|
|
query = query.Where(sq.Expr("ThreadMemberships.LastViewed < Threads.LastReplyAt"))
|
|
}
|
|
|
|
order := "DESC"
|
|
if opts.Before != "" {
|
|
query = query.Where(sq.Expr(`Threads.LastReplyAt < (SELECT LastReplyAt FROM Threads WHERE PostId = ?)`, opts.Before))
|
|
}
|
|
if opts.After != "" {
|
|
order = "ASC"
|
|
query = query.Where(sq.Expr(`Threads.LastReplyAt > (SELECT LastReplyAt FROM Threads WHERE PostId = ?)`, opts.After))
|
|
}
|
|
|
|
query = query.
|
|
OrderBy("Threads.LastReplyAt " + order).
|
|
Limit(pageSize)
|
|
|
|
var threads []*JoinedThread
|
|
err := s.GetReplicaX().SelectBuilder(&threads, query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to fetch threads for user id=%s", userId)
|
|
}
|
|
|
|
// Build the de-duplicated set of user ids representing participants across all threads.
|
|
var participantUserIds []string
|
|
for _, thread := range threads {
|
|
for _, participantUserId := range thread.Participants {
|
|
participantUserIds = append(participantUserIds, participantUserId)
|
|
}
|
|
}
|
|
participantUserIds = model.RemoveDuplicateStrings(participantUserIds)
|
|
|
|
// Resolve the user objects for all participants, with extended metadata if requested.
|
|
allParticipants := make(map[string]*model.User, len(participantUserIds))
|
|
if opts.Extended {
|
|
users, err := s.User().GetProfileByIds(context.Background(), participantUserIds, &store.UserGetByIdsOpts{}, true)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get %d thread profiles for user id=%s", len(participantUserIds), userId)
|
|
}
|
|
for _, user := range users {
|
|
allParticipants[user.Id] = user
|
|
}
|
|
} else {
|
|
for _, participantUserId := range participantUserIds {
|
|
allParticipants[participantUserId] = &model.User{Id: participantUserId}
|
|
}
|
|
}
|
|
|
|
result := make([]*model.ThreadResponse, 0, len(threads))
|
|
for _, thread := range threads {
|
|
result = append(result, thread.toThreadResponse(allParticipants))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetTeamsUnreadForUser returns the total unread threads and unread mentions
|
|
// for a user from all teams.
|
|
func (s *SqlThreadStore) GetTeamsUnreadForUser(userID string, teamIDs []string, includeUrgentMentionCount bool) (map[string]*model.TeamUnread, error) {
|
|
fetchConditions := sq.And{
|
|
sq.Eq{"ThreadMemberships.UserId": userID},
|
|
sq.Eq{"ThreadMemberships.Following": true},
|
|
sq.Eq{"Threads.ThreadTeamId": teamIDs},
|
|
sq.Eq{"COALESCE(Threads.ThreadDeleteAt, 0)": 0},
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
|
|
unreadThreads := []struct {
|
|
Count int64
|
|
TeamId string
|
|
}{}
|
|
unreadMentions := []struct {
|
|
Count int64
|
|
TeamId string
|
|
}{}
|
|
unreadUrgentMentions := []struct {
|
|
Count int64
|
|
TeamId string
|
|
}{}
|
|
|
|
// Running these concurrently hasn't shown any major downside
|
|
// than running them serially. So using a bit of perf boost.
|
|
// In any case, they will be replaced by computed columns later.
|
|
eg.Go(func() error {
|
|
repliesQuery := s.getQueryBuilder().
|
|
Select("COUNT(Threads.PostId) AS Count, ThreadTeamId AS TeamId").
|
|
From("Threads").
|
|
LeftJoin("ThreadMemberships ON Threads.PostId = ThreadMemberships.PostId").
|
|
Where(fetchConditions).
|
|
Where("Threads.LastReplyAt > ThreadMemberships.LastViewed").
|
|
GroupBy("Threads.ThreadTeamId")
|
|
|
|
return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadThreads, repliesQuery), "failed to get total unread threads")
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
mentionsQuery := s.getQueryBuilder().
|
|
Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0) AS Count, ThreadTeamId AS TeamId").
|
|
From("ThreadMemberships").
|
|
LeftJoin("Threads ON Threads.PostId = ThreadMemberships.PostId").
|
|
Where(fetchConditions).
|
|
GroupBy("Threads.ThreadTeamId")
|
|
|
|
return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadMentions, mentionsQuery), "failed to get total unread mentions")
|
|
})
|
|
|
|
if includeUrgentMentionCount {
|
|
eg.Go(func() error {
|
|
urgentMentionsQuery := s.getQueryBuilder().
|
|
Select("COALESCE(SUM(ThreadMemberships.UnreadMentions),0) AS Count, ThreadTeamId AS TeamId").
|
|
From("ThreadMemberships").
|
|
LeftJoin("Threads ON Threads.PostId = ThreadMemberships.PostId").
|
|
Join("PostsPriority ON PostsPriority.PostId = ThreadMemberships.PostId").
|
|
Where(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}).
|
|
Where(fetchConditions).
|
|
GroupBy("Threads.ThreadTeamId")
|
|
|
|
return errors.Wrap(s.GetReplicaX().SelectBuilder(&unreadUrgentMentions, urgentMentionsQuery), "failed to get total unread urgent mentions")
|
|
})
|
|
}
|
|
|
|
// Wait for them to be over
|
|
if err := eg.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := make(map[string]*model.TeamUnread)
|
|
// A bit of linear complexity here to create and return the map.
|
|
// This makes it easy to consume the output in the app layer.
|
|
for _, item := range unreadThreads {
|
|
res[item.TeamId] = &model.TeamUnread{
|
|
ThreadCount: item.Count,
|
|
}
|
|
}
|
|
for _, item := range unreadMentions {
|
|
if _, ok := res[item.TeamId]; ok {
|
|
res[item.TeamId].ThreadMentionCount = item.Count
|
|
} else {
|
|
res[item.TeamId] = &model.TeamUnread{
|
|
ThreadMentionCount: item.Count,
|
|
}
|
|
}
|
|
}
|
|
for _, item := range unreadUrgentMentions {
|
|
if _, ok := res[item.TeamId]; ok {
|
|
res[item.TeamId].ThreadUrgentMentionCount = item.Count
|
|
} else {
|
|
res[item.TeamId] = &model.TeamUnread{
|
|
ThreadUrgentMentionCount: item.Count,
|
|
}
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) GetThreadFollowers(threadID string, fetchOnlyActive bool) ([]string, error) {
|
|
users := []string{}
|
|
|
|
fetchConditions := sq.And{
|
|
sq.Eq{"PostId": threadID},
|
|
}
|
|
|
|
if fetchOnlyActive {
|
|
fetchConditions = sq.And{
|
|
sq.Eq{"Following": true},
|
|
fetchConditions,
|
|
}
|
|
}
|
|
|
|
query := s.getQueryBuilder().
|
|
Select("ThreadMemberships.UserId").
|
|
From("ThreadMemberships").
|
|
Where(fetchConditions)
|
|
|
|
err := s.GetReplicaX().SelectBuilder(&users, query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get thread followers for thread id=%s", threadID)
|
|
}
|
|
|
|
return users, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) GetThreadForUser(threadMembership *model.ThreadMembership, extended, postPriorityEnabled bool) (*model.ThreadResponse, error) {
|
|
if !threadMembership.Following {
|
|
return nil, store.NewErrNotFound("ThreadMembership", "<following>")
|
|
}
|
|
|
|
unreadRepliesQuery := sq.
|
|
Select("COUNT(Posts.Id)").
|
|
From("Posts").
|
|
Where(sq.And{
|
|
sq.Eq{"Posts.RootId": threadMembership.PostId},
|
|
sq.Gt{"Posts.CreateAt": threadMembership.LastViewed},
|
|
sq.Eq{"Posts.DeleteAt": 0},
|
|
})
|
|
|
|
query := s.threadsAndPostsSelectQuery
|
|
|
|
for _, c := range postSliceColumns() {
|
|
query = query.Column("Posts." + c)
|
|
}
|
|
|
|
var thread JoinedThread
|
|
query = query.
|
|
Column(sq.Alias(unreadRepliesQuery, "UnreadReplies")).
|
|
LeftJoin("Posts ON Posts.Id = Threads.PostId").
|
|
Where(sq.Eq{"Threads.PostId": threadMembership.PostId})
|
|
|
|
if postPriorityEnabled {
|
|
urgencyCase := sq.
|
|
Case().
|
|
When(sq.Eq{"PostsPriority.Priority": model.PostPriorityUrgent}, "true").
|
|
Else("false")
|
|
|
|
query = query.
|
|
Column(sq.Alias(urgencyCase, "IsUrgent")).
|
|
LeftJoin("PostsPriority ON PostsPriority.PostId = Threads.PostId")
|
|
}
|
|
|
|
err := s.GetReplicaX().GetBuilder(&thread, query)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Thread", threadMembership.PostId)
|
|
}
|
|
return nil, errors.Wrapf(err, "failed to get thread for user id=%s, post id=%s", threadMembership.UserId, threadMembership.PostId)
|
|
}
|
|
|
|
thread.LastViewedAt = threadMembership.LastViewed
|
|
thread.UnreadMentions = threadMembership.UnreadMentions
|
|
|
|
users := []*model.User{}
|
|
if extended {
|
|
var err error
|
|
users, err = s.User().GetProfileByIds(context.Background(), thread.Participants, &store.UserGetByIdsOpts{}, true)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get thread for user id=%s", threadMembership.UserId)
|
|
}
|
|
} else {
|
|
for _, userId := range thread.Participants {
|
|
users = append(users, &model.User{Id: userId})
|
|
}
|
|
}
|
|
|
|
usersMap := make(map[string]*model.User)
|
|
for _, user := range users {
|
|
usersMap[user.Id] = user
|
|
}
|
|
|
|
return thread.toThreadResponse(usersMap), nil
|
|
}
|
|
|
|
// MarkAllAsReadByChannels marks thread membership for the given users in the given channels
|
|
// as read. This is used by the application layer to keep threads up-to-date when CRT is disabled
|
|
// for the enduser, avoiding an influx of unread threads when first turning the feature on.
|
|
func (s *SqlThreadStore) MarkAllAsReadByChannels(userID string, channelIDs []string) error {
|
|
if len(channelIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
now := model.GetMillis()
|
|
|
|
var query sq.UpdateBuilder
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
query = s.getQueryBuilder().Update("ThreadMemberships").From("Threads")
|
|
|
|
} else {
|
|
query = s.getQueryBuilder().Update("ThreadMemberships", "Threads")
|
|
}
|
|
|
|
query = query.Set("LastViewed", now).
|
|
Set("UnreadMentions", 0).
|
|
Set("LastUpdated", now).
|
|
Where(sq.Eq{"ThreadMemberships.UserId": userID}).
|
|
Where(sq.Expr("Threads.PostId = ThreadMemberships.PostId")).
|
|
Where(sq.Eq{"Threads.ChannelId": channelIDs}).
|
|
Where(sq.Expr("Threads.LastReplyAt > ThreadMemberships.LastViewed"))
|
|
|
|
if _, err := s.GetMasterX().ExecBuilder(query); err != nil {
|
|
return errors.Wrapf(err, "failed to mark all threads as read by channels for user id=%s", userID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) MarkAllAsRead(userId string, threadIds []string) error {
|
|
timestamp := model.GetMillis()
|
|
|
|
query := s.getQueryBuilder().
|
|
Update("ThreadMemberships").
|
|
Where(sq.Eq{"UserId": userId}).
|
|
Where(sq.Eq{"PostId": threadIds}).
|
|
Set("LastViewed", timestamp).
|
|
Set("UnreadMentions", 0).
|
|
Set("LastUpdated", model.GetMillis())
|
|
|
|
_, err := s.GetMasterX().ExecBuilder(query)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to mark %d threads as read for user id=%s", len(threadIds), userId)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkAllAsReadByTeam marks all threads for the given user in the given team as read from the
|
|
// current time.
|
|
func (s *SqlThreadStore) MarkAllAsReadByTeam(userId, teamId string) error {
|
|
timestamp := model.GetMillis()
|
|
|
|
var query sq.UpdateBuilder
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
query = s.getQueryBuilder().Update("ThreadMemberships").From("Threads")
|
|
} else {
|
|
query = s.getQueryBuilder().Update("ThreadMemberships", "Threads")
|
|
}
|
|
|
|
query = query.
|
|
Where("Threads.PostId = ThreadMemberships.PostId").
|
|
Where(sq.Eq{"ThreadMemberships.UserId": userId}).
|
|
Where(sq.Or{sq.Eq{"Threads.ThreadTeamId": teamId}, sq.Eq{"Threads.ThreadTeamId": ""}}).
|
|
Set("LastViewed", timestamp).
|
|
Set("UnreadMentions", 0).
|
|
Set("LastUpdated", timestamp)
|
|
|
|
_, err := s.GetMasterX().ExecBuilder(query)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to update thread read state for user id=%s", userId)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkAsRead marks the given thread for the given user as unread from the given timestamp.
|
|
func (s *SqlThreadStore) MarkAsRead(userId, threadId string, timestamp int64) error {
|
|
query := s.getQueryBuilder().
|
|
Update("ThreadMemberships").
|
|
Where(sq.Eq{"UserId": userId}).
|
|
Where(sq.Eq{"PostId": threadId}).
|
|
Set("LastViewed", timestamp).
|
|
Set("LastUpdated", model.GetMillis())
|
|
|
|
_, err := s.GetMasterX().ExecBuilder(query)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to update thread read state for user id=%s thread_id=%v", userId, threadId)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) saveMembership(ex sqlxExecutor, membership *model.ThreadMembership) (*model.ThreadMembership, error) {
|
|
query := s.getQueryBuilder().
|
|
Insert("ThreadMemberships").
|
|
Columns("PostId", "UserId", "Following", "LastViewed", "LastUpdated", "UnreadMentions").
|
|
Values(membership.PostId, membership.UserId, membership.Following, membership.LastViewed, membership.LastUpdated, membership.UnreadMentions)
|
|
|
|
_, err := ex.ExecBuilder(query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to save thread membership with postid=%s userid=%s", membership.PostId, membership.UserId)
|
|
}
|
|
|
|
return membership, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) UpdateMembership(membership *model.ThreadMembership) (*model.ThreadMembership, error) {
|
|
return s.updateMembership(s.GetMasterX(), membership)
|
|
}
|
|
|
|
func (s *SqlThreadStore) updateMembership(ex sqlxExecutor, membership *model.ThreadMembership) (*model.ThreadMembership, error) {
|
|
query := s.getQueryBuilder().
|
|
Update("ThreadMemberships").
|
|
Set("Following", membership.Following).
|
|
Set("LastViewed", membership.LastViewed).
|
|
Set("LastUpdated", membership.LastUpdated).
|
|
Set("UnreadMentions", membership.UnreadMentions).
|
|
Where(sq.And{
|
|
sq.Eq{"PostId": membership.PostId},
|
|
sq.Eq{"UserId": membership.UserId},
|
|
})
|
|
|
|
_, err := ex.ExecBuilder(query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to update thread membership with postid=%s userid=%s", membership.PostId, membership.UserId)
|
|
}
|
|
|
|
return membership, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) GetMembershipsForUser(userId, teamId string) ([]*model.ThreadMembership, error) {
|
|
memberships := []*model.ThreadMembership{}
|
|
|
|
query := s.getQueryBuilder().
|
|
Select("ThreadMemberships.*").
|
|
Join("Threads ON Threads.PostId = ThreadMemberships.PostId").
|
|
From("ThreadMemberships").
|
|
Where(sq.Or{sq.Eq{"Threads.ThreadTeamId": teamId}, sq.Eq{"Threads.ThreadTeamId": ""}}).
|
|
Where(sq.Eq{"ThreadMemberships.UserId": userId})
|
|
|
|
err := s.GetReplicaX().SelectBuilder(&memberships, query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get thread membership with userid=%s", userId)
|
|
}
|
|
return memberships, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) GetMembershipForUser(userId, postId string) (*model.ThreadMembership, error) {
|
|
return s.getMembershipForUser(s.GetReplicaX(), userId, postId)
|
|
}
|
|
|
|
func (s *SqlThreadStore) getMembershipForUser(ex sqlxExecutor, userId, postId string) (*model.ThreadMembership, error) {
|
|
var membership model.ThreadMembership
|
|
query := s.getQueryBuilder().
|
|
Select("*").
|
|
From("ThreadMemberships").
|
|
Where(sq.And{
|
|
sq.Eq{"PostId": postId},
|
|
sq.Eq{"UserId": userId},
|
|
})
|
|
|
|
err := ex.GetBuilder(&membership, query)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Thread", postId)
|
|
}
|
|
return nil, errors.Wrapf(err, "failed to get thread membership with userid=%s postid=%s", userId, postId)
|
|
}
|
|
|
|
return &membership, nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) DeleteMembershipForUser(userId string, postId string) error {
|
|
query := s.getQueryBuilder().
|
|
Delete("ThreadMemberships").
|
|
Where(sq.And{
|
|
sq.Eq{"PostId": postId},
|
|
sq.Eq{"UserId": userId},
|
|
})
|
|
|
|
_, err := s.GetMasterX().ExecBuilder(query)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to delete thread membership")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MaintainMembership creates or updates a thread membership for the given user
|
|
// and post. This method is used to update the state of a membership in response
|
|
// to some events like:
|
|
// - post creation (mentions handling)
|
|
// - channel marked unread
|
|
// - user explicitly following a thread
|
|
func (s *SqlThreadStore) MaintainMembership(userId, postId string, opts store.ThreadMembershipOpts) (_ *model.ThreadMembership, err error) {
|
|
trx, err := s.GetMasterX().Beginx()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(trx, &err)
|
|
|
|
membership, err := s.getMembershipForUser(trx, userId, postId)
|
|
now := utils.MillisFromTime(time.Now())
|
|
// if membership exists, update it if:
|
|
// a. user started/stopped following a thread
|
|
// b. mention count changed
|
|
// c. user viewed a thread
|
|
if err == nil {
|
|
followingNeedsUpdate := (opts.UpdateFollowing && (membership.Following != opts.Following))
|
|
if followingNeedsUpdate || opts.IncrementMentions || opts.UpdateViewedTimestamp {
|
|
if followingNeedsUpdate {
|
|
membership.Following = opts.Following
|
|
}
|
|
if opts.UpdateViewedTimestamp {
|
|
membership.LastViewed = now
|
|
membership.UnreadMentions = 0
|
|
} else if opts.IncrementMentions {
|
|
membership.UnreadMentions += 1
|
|
}
|
|
membership.LastUpdated = now
|
|
if _, err = s.updateMembership(trx, membership); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err = trx.Commit(); err != nil {
|
|
return nil, errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return membership, err
|
|
}
|
|
|
|
var nfErr *store.ErrNotFound
|
|
if !errors.As(err, &nfErr) {
|
|
return nil, errors.Wrap(err, "failed to get thread membership")
|
|
}
|
|
|
|
membership = &model.ThreadMembership{
|
|
PostId: postId,
|
|
UserId: userId,
|
|
Following: opts.Following,
|
|
LastUpdated: now,
|
|
}
|
|
if opts.IncrementMentions {
|
|
membership.UnreadMentions = 1
|
|
}
|
|
if opts.UpdateViewedTimestamp {
|
|
membership.LastViewed = now
|
|
}
|
|
membership, err = s.saveMembership(trx, membership)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if opts.UpdateParticipants {
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
if _, err2 := trx.ExecRaw(`UPDATE Threads
|
|
SET participants = participants || $1::jsonb
|
|
WHERE postid=$2
|
|
AND NOT participants ? $3`, jsonArray([]string{userId}), postId, userId); err2 != nil {
|
|
return nil, err2
|
|
}
|
|
} else {
|
|
// CONCAT('$[', JSON_LENGTH(Participants), ']') just generates $[n]
|
|
// which is the positional syntax required for appending.
|
|
if _, err2 := trx.Exec(`UPDATE Threads
|
|
SET Participants = JSON_ARRAY_INSERT(Participants, CONCAT('$[', JSON_LENGTH(Participants), ']'), ?)
|
|
WHERE PostId=?
|
|
AND NOT JSON_CONTAINS(Participants, ?)`, userId, postId, strconv.Quote(userId)); err2 != nil {
|
|
return nil, err2
|
|
}
|
|
}
|
|
}
|
|
|
|
if err = trx.Commit(); err != nil {
|
|
return nil, errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return membership, err
|
|
}
|
|
|
|
// PermanentDeleteBatchForRetentionPolicies deletes a batch of records which are affected by
|
|
// the global or a granular retention policy.
|
|
// See `genericPermanentDeleteBatchForRetentionPolicies` for details.
|
|
func (s *SqlThreadStore) PermanentDeleteBatchForRetentionPolicies(now, globalPolicyEndTime, limit int64, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error) {
|
|
builder := s.getQueryBuilder().
|
|
Select("Threads.PostId").
|
|
From("Threads")
|
|
return genericPermanentDeleteBatchForRetentionPolicies(RetentionPolicyBatchDeletionInfo{
|
|
BaseBuilder: builder,
|
|
Table: "Threads",
|
|
TimeColumn: "LastReplyAt",
|
|
PrimaryKeys: []string{"PostId"},
|
|
ChannelIDTable: "Threads",
|
|
NowMillis: now,
|
|
GlobalPolicyEndTime: globalPolicyEndTime,
|
|
Limit: limit,
|
|
}, s.SqlStore, cursor)
|
|
}
|
|
|
|
// PermanentDeleteBatchThreadMembershipsForRetentionPolicies deletes a batch of records
|
|
// which are affected by the global or a granular retention policy.
|
|
// See `genericPermanentDeleteBatchForRetentionPolicies` for details.
|
|
func (s *SqlThreadStore) PermanentDeleteBatchThreadMembershipsForRetentionPolicies(now, globalPolicyEndTime, limit int64, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error) {
|
|
builder := s.getQueryBuilder().
|
|
Select("ThreadMemberships.PostId").
|
|
From("ThreadMemberships").
|
|
InnerJoin("Threads ON ThreadMemberships.PostId = Threads.PostId")
|
|
return genericPermanentDeleteBatchForRetentionPolicies(RetentionPolicyBatchDeletionInfo{
|
|
BaseBuilder: builder,
|
|
Table: "ThreadMemberships",
|
|
TimeColumn: "LastUpdated",
|
|
PrimaryKeys: []string{"PostId"},
|
|
ChannelIDTable: "Threads",
|
|
NowMillis: now,
|
|
GlobalPolicyEndTime: globalPolicyEndTime,
|
|
Limit: limit,
|
|
}, s.SqlStore, cursor)
|
|
}
|
|
|
|
// DeleteOrphanedRows removes orphaned rows from Threads and ThreadMemberships
|
|
func (s *SqlThreadStore) DeleteOrphanedRows(limit int) (deleted int64, err error) {
|
|
var threadsQuery string
|
|
// We need the extra level of nesting to deal with MySQL's locking
|
|
if s.DriverName() == model.DatabaseDriverMysql {
|
|
// MySQL fails to do a proper antijoin if the selecting column
|
|
// and the joining column are different. In that case, doing a subquery
|
|
// leads to a faster plan because MySQL materializes the sub-query
|
|
// and does a covering index scan on Threads table. More details on the PR with
|
|
// this commit.
|
|
threadsQuery = `
|
|
DELETE FROM Threads WHERE PostId IN (
|
|
SELECT * FROM (
|
|
SELECT Threads.PostId FROM Threads
|
|
WHERE Threads.ChannelId NOT IN (SELECT Id FROM Channels USE INDEX(PRIMARY))
|
|
LIMIT ?
|
|
) AS A
|
|
)`
|
|
} else {
|
|
threadsQuery = `
|
|
DELETE FROM Threads WHERE PostId IN (
|
|
SELECT * FROM (
|
|
SELECT Threads.PostId FROM Threads
|
|
LEFT JOIN Channels ON Threads.ChannelId = Channels.Id
|
|
WHERE Channels.Id IS NULL
|
|
LIMIT ?
|
|
) AS A
|
|
)`
|
|
}
|
|
// We only delete a thread membership if the entire thread no longer exists,
|
|
// not if the root post has been deleted
|
|
const threadMembershipsQuery = `
|
|
DELETE FROM ThreadMemberships WHERE PostId IN (
|
|
SELECT * FROM (
|
|
SELECT ThreadMemberships.PostId FROM ThreadMemberships
|
|
LEFT JOIN Threads ON ThreadMemberships.PostId = Threads.PostId
|
|
WHERE Threads.PostId IS NULL
|
|
LIMIT ?
|
|
) AS A
|
|
)`
|
|
result, err := s.GetMasterX().Exec(threadsQuery, limit)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rpcDeleted, err := result.RowsAffected()
|
|
if err != nil {
|
|
return
|
|
}
|
|
result, err = s.GetMasterX().Exec(threadMembershipsQuery, limit)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rptDeleted, err := result.RowsAffected()
|
|
if err != nil {
|
|
return
|
|
}
|
|
deleted = rpcDeleted + rptDeleted
|
|
return
|
|
}
|
|
|
|
// return number of unread replies for a single thread
|
|
func (s *SqlThreadStore) GetThreadUnreadReplyCount(threadMembership *model.ThreadMembership) (int64, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("COUNT(Posts.Id)").
|
|
From("Posts").
|
|
Where(sq.And{
|
|
sq.Eq{"Posts.RootId": threadMembership.PostId},
|
|
sq.Gt{"Posts.CreateAt": threadMembership.LastViewed},
|
|
sq.Eq{"Posts.DeleteAt": 0},
|
|
})
|
|
|
|
var unreadReplies int64
|
|
err := s.GetReplicaX().GetBuilder(&unreadReplies, query)
|
|
if err != nil {
|
|
return 0, errors.Wrapf(err, "failed to count unread reply count for post id=%s", threadMembership.PostId)
|
|
}
|
|
|
|
return unreadReplies, nil
|
|
}
|
|
|
|
// Top threads in all public channels and private channels userID is a member of. Returns a list of threads ranked by interactions.
|
|
func (s *SqlThreadStore) GetTopThreadsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopThreadList, error) {
|
|
var args []any
|
|
query := `select
|
|
threads_list.PostId,
|
|
threads_list.ReplyCount,
|
|
threads_list.ChannelId,
|
|
threads_list.DisplayName,
|
|
threads_list.Name,
|
|
threads_list.Participants,
|
|
p.UserId
|
|
from((
|
|
SELECT
|
|
t.PostId,
|
|
t.ReplyCount,
|
|
t.ChannelId,
|
|
t.Participants,
|
|
c.DisplayName,
|
|
c.Name
|
|
FROM
|
|
Threads t
|
|
LEFT JOIN PublicChannels c ON t.ChannelId = c.Id
|
|
WHERE
|
|
t.threaddeleteat IS NULL
|
|
AND t.LastReplyAt > ?
|
|
AND c.TeamId = ?
|
|
GROUP BY
|
|
t.PostId,
|
|
c.DisplayName,
|
|
c.Name,
|
|
t.Participants
|
|
)
|
|
UNION
|
|
ALL (
|
|
SELECT
|
|
t.PostId,
|
|
t.ReplyCount,
|
|
t.ChannelId,
|
|
t.Participants,
|
|
c.DisplayName,
|
|
c.Name
|
|
FROM
|
|
Threads t
|
|
LEFT JOIN ChannelMembers cm ON t.ChannelId = cm.ChannelId
|
|
LEFT JOIN Channels c ON t.ChannelId = c.Id
|
|
WHERE
|
|
t.threaddeleteat IS NULL
|
|
AND cm.UserId = ?
|
|
AND c.Type = 'P'
|
|
AND c.TeamId = ?
|
|
AND t.LastReplyAt > ?
|
|
GROUP BY
|
|
t.PostId,
|
|
c.DisplayName,
|
|
c.Name,
|
|
t.Participants
|
|
)) as threads_list
|
|
LEFT JOIN Posts as p on p.Id = threads_list.PostId
|
|
ORDER BY ReplyCount DESC
|
|
limit ? offset ?`
|
|
|
|
args = append(args, since, teamID, userID, teamID, since, limit+1, offset)
|
|
|
|
topThreads := make([]*model.TopThread, 0)
|
|
err := s.GetReplicaX().Select(&topThreads, query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get top threads=%s", teamID)
|
|
}
|
|
topThreads, err = postProcessTopThreads(topThreads, s, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return model.GetTopThreadListWithPagination(topThreads, limit), nil
|
|
}
|
|
|
|
func (s *SqlThreadStore) GetTopThreadsForUserSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopThreadList, error) {
|
|
var args []any
|
|
|
|
// gets all threads within the team which user follows.
|
|
query := `select
|
|
threads_list.PostId,
|
|
threads_list.ReplyCount,
|
|
threads_list.ChannelId,
|
|
threads_list.DisplayName,
|
|
threads_list.Name,
|
|
threads_list.Participants,
|
|
p.UserId
|
|
from((
|
|
SELECT
|
|
t.PostId,
|
|
t.ReplyCount,
|
|
t.ChannelId,
|
|
t.Participants,
|
|
c.DisplayName,
|
|
c.Name
|
|
FROM
|
|
Threads t
|
|
LEFT JOIN PublicChannels c ON t.ChannelId = c.Id
|
|
LEFT JOIN ThreadMemberships as tm on t.PostId = tm.PostId
|
|
WHERE
|
|
t.threaddeleteat IS NULL
|
|
AND t.LastReplyAt > ?
|
|
AND c.TeamId = ?
|
|
AND tm.UserId = ?
|
|
AND tm.Following = TRUE
|
|
GROUP BY
|
|
t.PostId,
|
|
c.DisplayName,
|
|
c.Name,
|
|
t.Participants
|
|
)
|
|
UNION
|
|
ALL (
|
|
SELECT
|
|
t.PostId,
|
|
t.ReplyCount,
|
|
t.ChannelId,
|
|
t.Participants,
|
|
c.DisplayName,
|
|
c.Name
|
|
FROM
|
|
Threads t
|
|
LEFT JOIN ChannelMembers cm ON t.ChannelId = cm.ChannelId
|
|
LEFT JOIN Channels c ON t.ChannelId = c.Id
|
|
LEFT JOIN ThreadMemberships as tm on t.PostId = tm.PostId
|
|
WHERE
|
|
cm.UserId = ?
|
|
AND c.Type = 'P'
|
|
AND c.TeamId = ?
|
|
AND t.threaddeleteat IS NULL
|
|
AND t.LastReplyAt > ?
|
|
AND tm.UserId = ?
|
|
AND tm.Following = TRUE
|
|
GROUP BY
|
|
t.PostId,
|
|
c.DisplayName,
|
|
c.Name,
|
|
t.Participants
|
|
)) as threads_list
|
|
LEFT JOIN Posts as p on p.Id = threads_list.PostId
|
|
ORDER BY ReplyCount DESC
|
|
limit ? offset ?`
|
|
|
|
args = append(args, since, teamID, userID, userID, teamID, since, userID, limit+1, offset)
|
|
|
|
topThreads := make([]*model.TopThread, 0)
|
|
err := s.GetReplicaX().Select(&topThreads, query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get top threads=%s", teamID)
|
|
}
|
|
topThreads, err = postProcessTopThreads(topThreads, s, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return model.GetTopThreadListWithPagination(topThreads, limit), nil
|
|
}
|
|
|
|
func userContains(userIDs []string, searchedUserID string) bool {
|
|
for _, userID := range userIDs {
|
|
if userID == searchedUserID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func postProcessTopThreads(topThreads []*model.TopThread, s *SqlThreadStore, teamID string) ([]*model.TopThread, error) {
|
|
// create list of userIDs
|
|
var userIDs []string
|
|
for _, topThread := range topThreads {
|
|
userID := topThread.UserId
|
|
if !userContains(userIDs, userID) {
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
}
|
|
|
|
usersMap := map[string]*model.User{}
|
|
|
|
users, err := s.User().GetProfileByIds(context.Background(), userIDs, &store.UserGetByIdsOpts{}, true)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get users for top threads in team=%s", teamID)
|
|
}
|
|
for _, user := range users {
|
|
usersMap[user.Id] = user
|
|
}
|
|
|
|
// resolve user, root post for each top thread
|
|
for _, topThread := range topThreads {
|
|
postCreator := usersMap[topThread.UserId]
|
|
topThread.UserInformation = &model.InsightUserInformation{
|
|
Id: postCreator.Id,
|
|
LastPictureUpdate: postCreator.LastPictureUpdate,
|
|
FirstName: postCreator.FirstName,
|
|
LastName: postCreator.LastName,
|
|
Username: postCreator.Username,
|
|
NickName: postCreator.Nickname,
|
|
}
|
|
post, err := s.Post().GetSingle(topThread.PostId, false)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get extended post for post id=%s", topThread.PostId)
|
|
}
|
|
topThread.Post = post
|
|
}
|
|
return topThreads, nil
|
|
}
|