From a5c6ea8e5d45d8ce2299328a05b2af70e6939d40 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:25:09 +0000 Subject: [PATCH 1/5] core: member relations vector migration; set index in group for members; rework logic for avoiding duplicate introductions (#6445) --- bots/api/TYPES.md | 1 + .../types/typescript/src/types.ts | 1 + simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Internal.hs | 4 +- src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 338 ++++++++++-------- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20251117_member_relations_vector.hs | 62 ++++ .../Store/Postgres/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20251117_member_relations_vector.hs | 106 ++++++ .../SQLite/Migrations/chat_query_plans.txt | 256 +++++++------ .../Store/SQLite/Migrations/chat_schema.sql | 9 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 1 + tests/ChatTests/Groups.hs | 2 +- 17 files changed, 534 insertions(+), 287 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 923fab14c2..9140f3b164 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -2198,6 +2198,7 @@ Known: **Record type**: - groupMemberId: int64 - groupId: int64 +- indexInGroup: int64 - memberId: string - memberRole: [GroupMemberRole](#groupmemberrole) - memberCategory: [GroupMemberCategory](#groupmembercategory) diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index b8095affdf..e67eae39a0 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2487,6 +2487,7 @@ export namespace GroupLinkPlan { export interface GroupMember { groupMemberId: number // int64 groupId: number // int64 + indexInGroup: number // int64 memberId: string memberRole: GroupMemberRole memberCategory: GroupMemberCategory diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a731f14eb9..bd3ca6e353 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -121,6 +121,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade + Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector else exposed-modules: Simplex.Chat.Archive @@ -266,6 +267,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade + Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 163a37cb3f..711a24c79e 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1034,14 +1034,14 @@ introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRol introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do - members <- withStore' $ \db -> getGroupMembers db vr user gInfo + members <- withStore' $ \db -> getGroupMembersForIntroduction db vr user gInfo m let recipients = filter memberCurrent members introduceMember vr user gInfo m recipients Nothing introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToRemaining vr user gInfo m = do (members, introducedGMIds) <- - withStore' $ \db -> (,) <$> getGroupMembers db vr user gInfo <*> getIntroducedGroupMemberIds db m + withStore' $ \db -> (,) <$> getGroupMembersForIntroduction db vr user gInfo m <*> getIntroducedGroupMemberIds db m let recipients = filter (introduceMemP introducedGMIds) members introduceMember vr user gInfo m recipients Nothing where diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 9467675272..bbb5b6a8c0 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -142,14 +142,14 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 00aa527603..3852665766 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -62,6 +62,7 @@ module Simplex.Chat.Store.Groups getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, + getGroupMembersForIntroduction, getGroupModerators, getGroupRelays, getGroupMembersForExpiration, @@ -190,11 +191,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -452,18 +453,35 @@ getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) +getUpdateNextIndexInGroup_ :: DB.Connection -> GroupId -> ExceptT StoreError IO Int64 +getUpdateNextIndexInGroup_ db groupId = + ExceptT . firstRow fromOnly (SEGroupNotFound groupId) $ + DB.query + db + [sql| + UPDATE groups + SET member_index = member_index + 1 + WHERE group_id = ? + RETURNING member_index + |] + (Only groupId) + createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId - (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of - (Just profile@LocalProfile {displayName}, Just profileId) -> - (,profile) <$> insertMemberIncognitoProfile_ displayName profileId - _ -> (,profile' userOrContact) <$> liftIO insertMember_ + (indexInGroup, localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of + (Just profile@LocalProfile {displayName}, Just profileId) -> do + (indexInGroup, localDisplayName) <- insertMemberIncognitoProfile_ displayName profileId + pure (indexInGroup, localDisplayName, profile) + _ -> do + (indexInGroup, localDisplayName) <- insertMember_ + pure (indexInGroup, localDisplayName, profile' userOrContact) groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory, @@ -484,40 +502,44 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe } where memberChatVRange@(VersionRange minV maxV) = vr - insertMember_ :: IO ContactName + insertMember_ :: ExceptT StoreError IO (Int64, ContactName) insertMember_ = do let localDisplayName = localDisplayName' userOrContact - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) - :. (minV, maxV) - ) - pure localDisplayName - insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO ContactName - insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = ExceptT $ - withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) :. (minV, maxV) ) - pure $ Right incognitoLdn + pure (indexInGroup, localDisplayName) + insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO (Int64, ContactName) + insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = + ExceptT . withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (minV, maxV) + ) + pure (indexInGroup, incognitoLdn) deleteContactCardKeepConn :: DB.Connection -> Int64 -> Contact -> IO () deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile {profileId}} = do @@ -542,16 +564,17 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines let memberId = MemberId $ encodeUtf8 groupLDN <> "_host_unknown_id" hostProfile = profileFromName $ nameFromMemberId memberId (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -737,16 +760,17 @@ createGroupViaLink' insertHost_ currentTs groupId = do (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember + indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -1000,6 +1024,14 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) +getGroupMembersForIntroduction :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> IO [GroupMember] +getGroupMembersForIntroduction db vr user@User {userId, userContactId} GroupInfo {groupId} _introduced@GroupMember {indexInGroup} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ?") + (userId, groupId, userContactId, indexInGroup) + getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) @@ -1067,21 +1099,22 @@ getGroupInvitation db vr user groupId = createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {connChatVersion, peerChatVRange}} memberRole agentConnId connRequest subMode = - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + void $ liftIO $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode pure member where VersionRange minV maxV = peerChatVRange invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do - insertMember_ + indexInGroup <- insertMember_ groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory = GCInviteeMember, @@ -1101,45 +1134,50 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, supportChat = Nothing } where - insertMember_ = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) - :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) - :. (minV, maxV) - ) + insertMember_ = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) + :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) + :. (minV, maxV) + ) + pure indexInGroup createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode - setCommandConnId db user cmdId connId + Connection {connId} <- liftIO $ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode + liftIO $ setCommandConnId db user cmdId connId where VersionRange minV maxV = peerChatVRange - insertMember_ memberId createdAt = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) - :. (minV, maxV) - ) + insertMember_ memberId createdAt = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) + :. (minV, maxV) + ) createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember @@ -1161,26 +1199,28 @@ createJoiningMember "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) profileId <- liftIO $ insertedRowId db - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do insertMember_ ldn profileId (MemberId memId) currentTs groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where VersionRange minV maxV = cReqChatVRange - insertMember_ ldn profileId memberId currentTs = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) - :. (minV, maxV) - ) + insertMember_ ldn profileId memberId currentTs = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) + :. (minV, maxV) + ) getMemberJoinRequest :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (Maybe (Maybe XContactId, Maybe SharedMsgId)) getMemberJoinRequest db User {userId} GroupInfo {groupId} GroupMember {groupMemberId = mId} = @@ -1242,22 +1282,24 @@ createBusinessRequestGroup membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr pure (groupId, membership) VersionRange minV maxV = cReqChatVRange - insertClientMember_ currentTs groupId membership = ExceptT $ do - withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do - createWithRandomId gVar $ \memId -> do - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) - :. (minV, maxV) - ) + insertClientMember_ currentTs groupId membership = + ExceptT . withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + createWithRandomId' gVar $ \memId -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + :. (minV, maxV) + ) groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) @@ -1417,7 +1459,7 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memContactId = Nothing, memProfileId } - liftIO $ createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember currentTs createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = @@ -1429,7 +1471,7 @@ createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDe profileId <- insertedRowId db pure $ Right (ldn, profileId) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -1449,24 +1491,27 @@ createNewMember_ let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange - DB.execute - db - [sql| - INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) - :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) - :. (minV, maxV) - ) - groupMemberId <- insertedRowId db + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) + :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + :. (minV, maxV) + ) + groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory, @@ -1523,33 +1568,20 @@ createIntroductions db chatV members toMember = do then pure [] else do currentTs <- getCurrentTime - catMaybes <$> mapM (createIntro_ currentTs) reMembers + mapM (insertIntro_ currentTs) reMembers where - createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMemberIntro) - createIntro_ ts reMember = - -- when members connect concurrently, host would try to create introductions between them in both directions; - -- this check avoids creating second (redundant) introduction - checkInverseIntro >>= \case - Just _ -> pure Nothing - Nothing -> do - DB.execute - db - [sql| - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - |] - (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) - introId <- insertedRowId db - pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} - where - checkInverseIntro :: IO (Maybe Int64) - checkInverseIntro = - maybeFirstRow fromOnly $ - DB.query - db - "SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1" - (groupMemberId' toMember, groupMemberId' reMember) + insertIntro_ :: UTCTime -> GroupMember -> IO GroupMemberIntro + insertIntro_ ts reMember = do + DB.execute + db + [sql| + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) + introId <- insertedRowId db + pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do @@ -1704,11 +1736,10 @@ createIntroReMember currentTs <- liftIO getCurrentTime (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - liftIO $ do - member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode - liftIO $ setCommandConnId db user groupCmdId groupConnId - pure (member :: GroupMember) {activeConn = Just conn} + member <- createNewMember_ db user gInfo newMember currentTs + conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode + liftIO $ setCommandConnId db user groupCmdId groupConnId + pure (member :: GroupMember) {activeConn = Just conn} createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do @@ -2468,21 +2499,22 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs - groupMemberId <- liftIO $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) - insertedRowId db + groupMemberId <- liftIO $ insertedRowId db getGroupMemberById db vr user groupMemberId where VersionRange minV maxV = vr diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 47aabd16ee..d7dd0fde01 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -674,7 +674,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe [sql| SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -2998,7 +2998,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -3006,13 +3006,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 89f8f6070b..9361713ea2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -21,6 +21,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade +import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -41,7 +42,8 @@ schemaMigrations = ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), - ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade) + ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), + ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs new file mode 100644 index 0000000000..197c90a66c --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251117_member_relations_vector :: Text +m20251117_member_relations_vector = + T.pack + [r| +ALTER TABLE group_members ADD COLUMN index_in_group BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN member_index BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN member_relations_vector BYTEA; + +CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); + +CREATE TEMPORARY TABLE tmp_members_numbered AS +SELECT + group_member_id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY group_member_id ASC + ) AS rn +FROM group_members; + +CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); + +UPDATE group_members AS gm +SET index_in_group = n.rn +FROM tmp_members_numbered n +WHERE n.group_member_id = gm.group_member_id; + +DROP INDEX tmp_idx_group_members_group_id_group_member_id; +DROP INDEX tmp_idx_members_numbered; +DROP TABLE tmp_members_numbered; + +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); + +UPDATE groups g +SET member_index = COALESCE(( + SELECT MAX(index_in_group) + FROM group_members + WHERE group_members.group_id = g.group_id +), 0); +|] + +down_m20251117_member_relations_vector :: Text +down_m20251117_member_relations_vector = + T.pack + [r| +DROP INDEX idx_group_members_group_id_index_in_group; + +ALTER TABLE group_members DROP COLUMN index_in_group; + +ALTER TABLE groups DROP COLUMN member_index; + +ALTER TABLE group_members DROP COLUMN member_relations_vector; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 601dd97a6e..712099d7c9 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -706,7 +706,9 @@ CREATE TABLE test_chat_schema.group_members ( support_chat_items_mentions bigint DEFAULT 0 NOT NULL, support_chat_last_msg_from_member_ts timestamp with time zone, member_xcontact_id bytea, - member_welcome_shared_msg_id bytea + member_welcome_shared_msg_id bytea, + index_in_group bigint DEFAULT 0 NOT NULL, + member_relations_vector bytea ); @@ -805,7 +807,8 @@ CREATE TABLE test_chat_schema.groups ( request_shared_msg_id bytea, conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, via_group_link_uri bytea, - summary_current_members_count bigint DEFAULT 0 NOT NULL + summary_current_members_count bigint DEFAULT 0 NOT NULL, + member_index bigint DEFAULT 0 NOT NULL ); @@ -2081,6 +2084,10 @@ CREATE INDEX idx_group_members_group_id ON test_chat_schema.group_members USING +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON test_chat_schema.group_members USING btree (group_id, index_in_group); + + + CREATE INDEX idx_group_members_invited_by ON test_chat_schema.group_members USING btree (invited_by); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 1c819e6537..13ada872b2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -144,6 +144,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade +import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -287,7 +288,8 @@ schemaMigrations = ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), - ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade) + ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), + ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs new file mode 100644 index 0000000000..09ebe63708 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -0,0 +1,106 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- to do list: +-- - directory migration +-- - background process to set member_relations_vector based on group_member_intros +-- - also set member_relations_vector on forward (recipient list for sender is known there) +-- - take member locks when updating member_relations_vector +-- - for duration of migration forwarding operates in 2 modes simultaneously: +-- - if member_relations_vector is set, use it +-- - otherwise, use existing logic based on group_member_intros +-- - new invitees start with member_relations_vector = 0 for all existing (pre) members -> +-- member_relations_vector immediately can be used when new invitee sends +-- - pre members are not updated right away for new invitee, if their member_relations_vector is not set yet, +-- as it will be costly to update them all at once; instead it will be set once background process processes them; +-- also this means group_member_intros have to be maintained for them until then +-- - GroupMember.memberStatusVector is Maybe to make this differentiation +-- - user clients migration +-- - once directory service migrates to new state, member_relations_vector can be updated in db migration +-- as user clients wouldn't have as large group_member_intros +-- - TBC migration SQL +-- - alternative approach for member_relations_vector migration (both directory and user clients): +-- - set to 0 for all existing members right away in sql migration +-- (possibly limit to groups where user is admin or above, otherwise NULL) +-- - means that initially after migration new messages will be forwarded to all members, +-- however they will quickly report connected state via XGrpMemCon -> member_relations_vector will self-adjust +-- - allows for simple migration path, with immediate switch from group_member_intros, +-- avoids complexity of dual-mode forwarding during migration for directory / complex sql migration for user clients +-- - rework forwarding logic to use member_relations_vector: +-- - create new members with correct index_in_group = group's member_index + 1, +-- maintain groups.member_index +-- - when new invitee joins, set member_relations_vector to all 0 for them, update for pre members (set 0 for invitee's seq id) +-- - on XGrpMemCon update bitvectors for sender and referenced member (set 1 for corresponding seq ids) +-- - don't maintain group_member_intros (don't create, update status) +-- - on forwarding, get recipients based on sender's member_relations_vector +-- - for all 0s in bitvector, get members by index_in_group in corresponding positions +-- - second use of group_member_intros is targeted introductions of knocking member to "remaining" members +-- - has to be reworked to not rely on group_member_intros +-- - one approach could be to introduce accepted member to all (so, repeatedly introduce to moderators), +-- this idea was tested in PR 6327 +-- - another use of group_member_intros - createIntroductions, checkInverseIntro logic +-- - TBC how to avoid making redundant introductions between concurrently joining members +-- - second vector - for member introductions, or track in same vector +-- - when introducing to moderators only, do nothing - new moderators are introduced only to current members, +-- no pending in progress members, so race can't happen there +-- - when introducing to all, filter out members who already were introduced to this member +-- - can also solve previous issue of introducing remaining members in same way - don't introduce +-- to members this member already was introduced to +m20251117_member_relations_vector :: Query +m20251117_member_relations_vector = + [sql| +ALTER TABLE group_members ADD COLUMN index_in_group INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN member_index INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN member_relations_vector BLOB; + +CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); + +CREATE TEMPORARY TABLE tmp_members_numbered AS +SELECT + group_member_id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY group_member_id ASC + ) AS rn +FROM group_members; + +CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); + +UPDATE group_members AS gm +SET index_in_group = ( + SELECT rn + FROM tmp_members_numbered + WHERE tmp_members_numbered.group_member_id = gm.group_member_id +); + +DROP INDEX tmp_idx_group_members_group_id_group_member_id; +DROP INDEX tmp_idx_members_numbered; +DROP TABLE tmp_members_numbered; + +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); + +UPDATE groups AS g +SET member_index = COALESCE(( + SELECT MAX(index_in_group) + FROM group_members + WHERE group_members.group_id = g.group_id +), 0); +|] + +down_m20251117_member_relations_vector :: Query +down_m20251117_member_relations_vector = + [sql| +DROP INDEX idx_group_members_group_id_index_in_group; + +ALTER TABLE group_members DROP COLUMN index_in_group; + +ALTER TABLE groups DROP COLUMN member_index; + +ALTER TABLE group_members DROP COLUMN member_relations_vector; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index dbb4823024..e343eadaeb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -22,6 +22,40 @@ Query: Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + Query: UPDATE groups SET chat_ts = ?, @@ -42,10 +76,10 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -113,14 +147,14 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -224,18 +258,11 @@ Plan: SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) SEARCH users USING INTEGER PRIMARY KEY (rowid=?) -Query: - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - -Plan: - Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -266,10 +293,44 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -431,9 +492,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -464,8 +525,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) @@ -496,40 +557,6 @@ SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_mem SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) -Query: - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - Query: INSERT INTO messages ( msg_sent, chat_msg_event, msg_body, connection_id, group_id, @@ -844,7 +871,7 @@ SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_mem Query: SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -962,12 +989,19 @@ Query: Plan: +Query: + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -1064,7 +1098,7 @@ Query: -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -1072,13 +1106,13 @@ Query: -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, @@ -1611,44 +1645,10 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -3790,6 +3790,15 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET member_index = member_index + 1 + WHERE group_id = ? + RETURNING member_index + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ? @@ -4460,7 +4469,7 @@ Query: SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? Plan: -SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) Query: SELECT DISTINCT group_id, worker_scope @@ -4970,7 +4979,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5004,7 +5013,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5031,7 +5040,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5080,7 +5089,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5107,7 +5116,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5126,7 +5135,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5139,13 +5148,13 @@ Query: LEFT JOIN connections c ON c.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.member_category = ? Plan: -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5164,7 +5173,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5183,7 +5192,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5202,7 +5211,26 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group Nothing toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = +toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -702,7 +702,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -742,7 +742,7 @@ groupInfoQueryFields = g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index bb86cb2522..667660c97b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -921,6 +921,7 @@ type GroupMemberId = Int64 data GroupMember = GroupMember { groupMemberId :: GroupMemberId, groupId :: GroupId, + indexInGroup :: Int64, memberId :: MemberId, memberRole :: GroupMemberRole, memberCategory :: GroupMemberCategory, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 52a015c31f..4c5efa26b6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3663,7 +3663,7 @@ testGroupMsgDecryptError ps = withTestChat ps "bob" $ \bob -> do bob <## "subscribed 2 connections on server localhost" alice #> "#team hello again" - bob <# "#team alice> skipped message ID 9..11" + bob <# "#team alice> skipped message ID 8..10" bob <# "#team alice> hello again" bob #> "#team received!" alice <# "#team bob> received!" From af7540248ece729fbc0bb216b2b107384ce69bf6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 24 Nov 2025 07:36:44 +0000 Subject: [PATCH 2/5] core: relations vector operations (#6459) * core: relations vector operations * tests * simplify * comment * remove space * core: use 1 byte for each member-to-member relation (#6462) --------- Co-authored-by: Evgeny --- simplex-chat.cabal | 2 + src/Simplex/Chat/Types/MemberRelations.hs | 74 ++++++++ tests/MemberRelationsTests.hs | 209 ++++++++++++++++++++++ tests/Test.hs | 2 + 4 files changed, 287 insertions(+) create mode 100644 src/Simplex/Chat/Types/MemberRelations.hs create mode 100644 tests/MemberRelationsTests.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bd3ca6e353..24ccf24ded 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -84,6 +84,7 @@ library Simplex.Chat.Store.Shared Simplex.Chat.Styled Simplex.Chat.Types + Simplex.Chat.Types.MemberRelations Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme @@ -544,6 +545,7 @@ test-suite simplex-chat-test JSONFixtures JSONTests MarkdownTests + MemberRelationsTests MessageBatching OperatorTests ProtocolTests diff --git a/src/Simplex/Chat/Types/MemberRelations.hs b/src/Simplex/Chat/Types/MemberRelations.hs new file mode 100644 index 0000000000..7245b22472 --- /dev/null +++ b/src/Simplex/Chat/Types/MemberRelations.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE LambdaCase #-} + +module Simplex.Chat.Types.MemberRelations + ( MemberRelation (..), + getRelation, + setRelation, + setRelations, + ) +where + +import Control.Monad +import Data.Bits ((.&.), (.|.), complement) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.ByteString.Internal (toForeignPtr, unsafeCreate) +import Data.Int (Int64) +import Data.Word (Word8) +import Foreign.ForeignPtr (withForeignPtr) +import Foreign.Marshal.Utils (copyBytes, fillBytes) +import Foreign.Ptr (plusPtr) +import Foreign.Storable (peekByteOff, pokeByteOff) + +data MemberRelation + = MRNew + | MRIntroduced + | MRConnected + deriving (Eq, Show) + +toRelationInt :: MemberRelation -> Word8 +toRelationInt = \case + MRNew -> 0 + MRIntroduced -> 1 + MRConnected -> 2 + +fromRelationInt :: Word8 -> MemberRelation +fromRelationInt = \case + 0 -> MRNew + 1 -> MRIntroduced + 2 -> MRConnected + _ -> MRNew + +-- | Get the relation status of a member at a given index from the relations vector. +-- Returns 'MRNew' if the vector is not long enough (lazy initialization). +getRelation :: Int64 -> ByteString -> MemberRelation +getRelation i v + | i < 0 || fromIntegral i >= B.length v = MRNew + | otherwise = fromRelationInt $ (v `B.index` fromIntegral i) .&. relationMask + + +-- | Set the relation status of a member at a given index in the relations vector. +-- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +setRelation :: Int64 -> MemberRelation -> ByteString -> ByteString +setRelation i r v + | i >= 0 = setRelations [(i, r)] v + | otherwise = v + +-- | Set multiple relations at once. +-- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +setRelations :: [(Int64, MemberRelation)] -> ByteString -> ByteString +setRelations [] v = v +setRelations relations v = + let (fp, off, len) = toForeignPtr v + newLen = max len $ fromIntegral $ maximum (map fst relations) + 1 + in unsafeCreate newLen $ \ptr -> do + withForeignPtr fp $ \vPtr -> copyBytes ptr (vPtr `plusPtr` off) len + when (newLen > len) $ fillBytes (ptr `plusPtr` len) 0 (newLen - len) + forM_ relations $ \(ix, r) -> when (ix >= 0) $ do + let i = fromIntegral ix + b <- peekByteOff ptr i + let b' = (b .&. complement relationMask) .|. toRelationInt r + pokeByteOff ptr i b' + +relationMask :: Word8 +relationMask = 0x07 -- reserving 3 bits diff --git a/tests/MemberRelationsTests.hs b/tests/MemberRelationsTests.hs new file mode 100644 index 0000000000..968dcbec43 --- /dev/null +++ b/tests/MemberRelationsTests.hs @@ -0,0 +1,209 @@ +{-# LANGUAGE OverloadedStrings #-} + +module MemberRelationsTests where + +import Control.Monad +import qualified Data.ByteString as B +import Simplex.Chat.Types.MemberRelations +import Test.Hspec + +memberRelationsTests :: Spec +memberRelationsTests = do + describe "MemberRelation vector operations" $ do + describe "getRelation" $ do + it "returns MRNew for empty vector" $ do + getRelation 0 B.empty `shouldBe` MRNew + getRelation 5 B.empty `shouldBe` MRNew + getRelation 100 B.empty `shouldBe` MRNew + + it "returns MRNew for negative index" $ do + getRelation (-1) B.empty `shouldBe` MRNew + getRelation (-5) (B.pack [0xFF]) `shouldBe` MRNew + + it "returns MRNew for index beyond vector length" $ do + let vec = B.pack [0x00] + getRelation 10 vec `shouldBe` MRNew + + it "reads single relation from byte" $ do + let vec = B.pack [0x01] + getRelation 0 vec `shouldBe` MRIntroduced + + it "reads multiple relations" $ do + let vec = B.pack [0, 0, 1, 2] + getRelation 0 vec `shouldBe` MRNew + getRelation 1 vec `shouldBe` MRNew + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 3 vec `shouldBe` MRConnected + + it "reads multiple relations 2" $ do + let vec = B.pack [1, 1, 0, 0, 2, 2, 0, 0] + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRIntroduced + getRelation 4 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRConnected + + it "ignore reserved bits" $ do + let vec = B.pack [0xF9] -- 11111001 + getRelation 0 vec `shouldBe` MRIntroduced + + describe "setRelation" $ do + it "sets relation in empty vector (lazy expansion)" $ do + let vec = setRelation 0 MRIntroduced B.empty + getRelation 0 vec `shouldBe` MRIntroduced + + it "ignores negative index" $ do + let vec = setRelation (-1) MRIntroduced B.empty + vec `shouldBe` B.empty + + it "expands vector to required length" $ do + let vec = setRelation 5 MRConnected B.empty + B.length vec `shouldBe` 6 + getRelation 5 vec `shouldBe` MRConnected + -- Other positions should be MRNew (0) + getRelation 0 vec `shouldBe` MRNew + getRelation 10 vec `shouldBe` MRNew + B.length vec `shouldBe` 6 + + it "updates existing relation without affecting others" $ do + -- Start: [01][01][00][00] + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 1 MRIntroduced vec1 + -- Update: [01][10][00][00] + let vec3 = setRelation 1 MRConnected vec2 + getRelation 0 vec3 `shouldBe` MRIntroduced + getRelation 1 vec3 `shouldBe` MRConnected + + it "updates relation in specific byte of multi-byte vector" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 10 MRConnected vec1 + B.length vec2 `shouldBe` 11 + getRelation 0 vec2 `shouldBe` MRIntroduced + getRelation 10 vec2 `shouldBe` MRConnected + forM_ [1..9] $ \i -> getRelation i vec2 `shouldBe` MRNew + + it "handles setting relation at last position in byte" $ do + let vec = setRelation 3 MRConnected B.empty + getRelation 3 vec `shouldBe` MRConnected + + it "preserves vector when setting same value" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 0 MRIntroduced vec1 + vec2 `shouldBe` vec1 + getRelation 0 vec2 `shouldBe` MRIntroduced + + it "preserves reserved bits" $ do + let v = B.pack [0xF8] -- 11111000 + getRelation 0 v `shouldBe` MRNew + let v' = setRelation 0 MRIntroduced v + getRelation 0 v' `shouldBe` MRIntroduced + B.unpack v' `shouldBe` [0xF9] -- 11111001 + + describe "setRelations" $ do + it "returns same vector for empty list" $ do + let vec = B.pack [0x42] + setRelations [] vec `shouldBe` vec + + it "sets multiple relations in empty vector" $ do + let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRConnected + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 3 vec `shouldBe` MRNew -- Unset position + + it "sets multiple relations 1" $ do + let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRConnected), (3, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 4 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRConnected + getRelation 2 vec `shouldBe` MRConnected + getRelation 3 vec `shouldBe` MRIntroduced + + it "sets multiple relations 2" $ do + let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 11 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 5 vec `shouldBe` MRConnected + getRelation 10 vec `shouldBe` MRIntroduced + getRelation 7 vec `shouldBe` MRNew -- Unset position between + + it "handles sparse updates (few indices in large range)" $ do + -- Sparse: 3 updates in large group + let updates = [(0, MRIntroduced), (100, MRConnected), (5000, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 100 vec `shouldBe` MRConnected + getRelation 5000 vec `shouldBe` MRIntroduced + getRelation 50 vec `shouldBe` MRNew -- Untouched position + + it "handles dense updates (many consecutive indices)" $ do + -- Dense: many consecutive updates + let updates = [(i, if even i then MRIntroduced else MRConnected) | i <- [0 .. 99]] + let vec = setRelations updates B.empty + all (\i -> getRelation i vec == (if even i then MRIntroduced else MRConnected)) [0 .. 99] `shouldBe` True + + it "handles unsorted input correctly" $ do + let updates = [(10, MRConnected), (2, MRIntroduced), (5, MRConnected), (0, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 5 vec `shouldBe` MRConnected + getRelation 10 vec `shouldBe` MRConnected + + it "handles duplicate indices (last one wins)" $ do + let updates = [(0, MRIntroduced), (0, MRConnected), (0, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + + it "preserves existing relations not in update list" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelation 5 MRIntroduced vec1 + let updates = [(10, MRConnected)] + let vec3 = setRelations updates vec2 + getRelation 0 vec3 `shouldBe` MRConnected + getRelation 5 vec3 `shouldBe` MRIntroduced + getRelation 10 vec3 `shouldBe` MRConnected + + describe "edge cases and invariants" $ do + it "round-trip: set then get returns same value" $ do + let vec1 = setRelation 42 MRConnected B.empty + getRelation 42 vec1 `shouldBe` MRConnected + + it "multiple round-trips preserve values" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 1 MRConnected vec1 + let vec3 = setRelation 2 MRIntroduced vec2 + getRelation 0 vec3 `shouldBe` MRIntroduced + getRelation 1 vec3 `shouldBe` MRConnected + getRelation 2 vec3 `shouldBe` MRIntroduced + + it "setRelations equivalent to multiple setRelation calls" $ do + let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let vecBatch = setRelations updates B.empty + let vecSeq = setRelation 10 MRIntroduced $ setRelation 5 MRConnected $ setRelation 0 MRIntroduced B.empty + vecBatch `shouldBe` vecSeq + getRelation 0 vecBatch `shouldBe` getRelation 0 vecSeq + getRelation 5 vecBatch `shouldBe` getRelation 5 vecSeq + getRelation 10 vecBatch `shouldBe` getRelation 10 vecSeq + + it "handles large group size (10000 members)" $ do + let updates = [(0, MRIntroduced), (5000, MRConnected), (9999, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 10000 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 5000 vec `shouldBe` MRConnected + getRelation 9999 vec `shouldBe` MRIntroduced + + it "all status values can be stored and retrieved" $ do + let vec1 = setRelation 0 MRNew B.empty + let vec2 = setRelation 1 MRIntroduced vec1 + let vec3 = setRelation 2 MRConnected vec2 + getRelation 0 vec3 `shouldBe` MRNew + getRelation 1 vec3 `shouldBe` MRIntroduced + getRelation 2 vec3 `shouldBe` MRConnected + + it "vector length is minimal (lazy expansion)" $ do + let vec = setRelation 3 MRConnected B.empty + B.length vec `shouldBe` 4 diff --git a/tests/Test.hs b/tests/Test.hs index e4e76fd43e..e1a5a58c7d 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -13,6 +13,7 @@ import Control.Logger.Simple import Data.Time.Clock.System import JSONTests import MarkdownTests +import MemberRelationsTests import MessageBatching import ProtocolTests import OperatorTests @@ -59,6 +60,7 @@ main = do #endif describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests + describe "Member relations" memberRelationsTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "Valid names" validNameTests From 65e3d8ca7cd5753dc9869280dfb4e1878484a624 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:43:33 +0000 Subject: [PATCH 3/5] core: fix relation vector indexes in code and migrations to start from 0 instead of 1 (#6460) --- src/Simplex/Chat/Store/Groups.hs | 2 +- .../M20251117_member_relations_vector.hs | 18 +++++------ .../M20251117_member_relations_vector.hs | 30 +++++++------------ .../SQLite/Migrations/chat_query_plans.txt | 2 +- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3852665766..c04a31c0a9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -462,7 +462,7 @@ getUpdateNextIndexInGroup_ db groupId = UPDATE groups SET member_index = member_index + 1 WHERE group_id = ? - RETURNING member_index + RETURNING member_index - 1 |] (Only groupId) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs index 197c90a66c..3f6deeb80f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -18,31 +18,31 @@ ALTER TABLE group_members ADD COLUMN member_relations_vector BYTEA; CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); -CREATE TEMPORARY TABLE tmp_members_numbered AS +CREATE TEMPORARY TABLE tmp_members_indexed AS SELECT group_member_id, ROW_NUMBER() OVER ( PARTITION BY group_id ORDER BY group_member_id ASC - ) AS rn + ) - 1 AS idx_in_group FROM group_members; -CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); +CREATE INDEX tmp_idx_members_indexed ON tmp_members_indexed(group_member_id); UPDATE group_members AS gm -SET index_in_group = n.rn -FROM tmp_members_numbered n -WHERE n.group_member_id = gm.group_member_id; +SET index_in_group = tmi.idx_in_group +FROM tmp_members_indexed tmi +WHERE tmi.group_member_id = gm.group_member_id; DROP INDEX tmp_idx_group_members_group_id_group_member_id; -DROP INDEX tmp_idx_members_numbered; -DROP TABLE tmp_members_numbered; +DROP INDEX tmp_idx_members_indexed; +DROP TABLE tmp_members_indexed; CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); UPDATE groups g SET member_index = COALESCE(( - SELECT MAX(index_in_group) + SELECT MAX(index_in_group) + 1 FROM group_members WHERE group_members.group_id = g.group_id ), 0); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs index 09ebe63708..5c4251cc55 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -40,16 +40,8 @@ import Database.SQLite.Simple.QQ (sql) -- - for all 0s in bitvector, get members by index_in_group in corresponding positions -- - second use of group_member_intros is targeted introductions of knocking member to "remaining" members -- - has to be reworked to not rely on group_member_intros --- - one approach could be to introduce accepted member to all (so, repeatedly introduce to moderators), --- this idea was tested in PR 6327 --- - another use of group_member_intros - createIntroductions, checkInverseIntro logic --- - TBC how to avoid making redundant introductions between concurrently joining members --- - second vector - for member introductions, or track in same vector --- - when introducing to moderators only, do nothing - new moderators are introduced only to current members, --- no pending in progress members, so race can't happen there --- - when introducing to all, filter out members who already were introduced to this member --- - can also solve previous issue of introducing remaining members in same way - don't introduce --- to members this member already was introduced to +-- - set MRIntroduced status in member relations vector on first introduction, +-- exclude previously introduced members on second introduction m20251117_member_relations_vector :: Query m20251117_member_relations_vector = [sql| @@ -61,33 +53,33 @@ ALTER TABLE group_members ADD COLUMN member_relations_vector BLOB; CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); -CREATE TEMPORARY TABLE tmp_members_numbered AS +CREATE TEMPORARY TABLE tmp_members_indexed AS SELECT group_member_id, ROW_NUMBER() OVER ( PARTITION BY group_id ORDER BY group_member_id ASC - ) AS rn + ) - 1 AS idx_in_group FROM group_members; -CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); +CREATE INDEX tmp_idx_members_indexed ON tmp_members_indexed(group_member_id); UPDATE group_members AS gm SET index_in_group = ( - SELECT rn - FROM tmp_members_numbered - WHERE tmp_members_numbered.group_member_id = gm.group_member_id + SELECT idx_in_group + FROM tmp_members_indexed + WHERE tmp_members_indexed.group_member_id = gm.group_member_id ); DROP INDEX tmp_idx_group_members_group_id_group_member_id; -DROP INDEX tmp_idx_members_numbered; -DROP TABLE tmp_members_numbered; +DROP INDEX tmp_idx_members_indexed; +DROP TABLE tmp_members_indexed; CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); UPDATE groups AS g SET member_index = COALESCE(( - SELECT MAX(index_in_group) + SELECT MAX(index_in_group) + 1 FROM group_members WHERE group_members.group_id = g.group_id ), 0); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index e343eadaeb..9156b56fcb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3794,7 +3794,7 @@ Query: UPDATE groups SET member_index = member_index + 1 WHERE group_id = ? - RETURNING member_index + RETURNING member_index - 1 Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) From 594a74e337296a4ac35a12605257cb3792441ca3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 25 Nov 2025 07:31:35 +0000 Subject: [PATCH 4/5] docs: relations vector rfc (#6463) --- .../2025-11-24-member-relations-vector.md | 133 ++++++++++++++++++ .../M20251117_member_relations_vector.hs | 37 ----- 2 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 docs/rfcs/2025-11-24-member-relations-vector.md diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md new file mode 100644 index 0000000000..c4e8cf7224 --- /dev/null +++ b/docs/rfcs/2025-11-24-member-relations-vector.md @@ -0,0 +1,133 @@ +# Member relations vectors + +## Problem + +Maintaining member introduction records takes N^2 space. + +## Solution + +Migrate to member relations byte vector, with per member relation encoded by member index. + +Requires: + +1. Per group member index (Done). +2. Primitives to work with byte vector (Done). +3. Rework forwarding logic to use relations vector. +4. Rework introductions logic to use relations vector (avoid duplicate introductions). +5. Migration from introductions to vector. + +Migration is 2-stage: + +1. Live migration to accommodate large volume of introductions data, with admin client choosing mode of operation based on presence of relation vector for member. +2. Offline migration of remaining introduction records. Drop mode of operation based on introductions. + +### Forwarding + +When new invitee connects (CON) -> host makes introductions: + +1. For this invitee: set member relations to 'MRIntroduced' for respective members. _**(Take member lock)**_ +2. For pre-members: + - Member has vector: Set relation to 'MRIntroducedTo' for invitee member - N updates. _**(Take member locks/take group lock?)**_ + - No vector: Create introduction record (Transitional mode of operation based on introductions). + +When member reports XGrpMemCon ("connected with another member"), for both reporting and referenced members: + +1. Member has vector: Set relation to 'MRConnected'. _**(Take member lock)**_ +2. No vector: Update introduction record status (Transitional). + +When member sends message -> host forwards: + +1. Member has vector: Get recipients based on sender relations vector ('MRIntroduced' + 'MRIntroducedTo' members). +2. No vector: Get recipients based on introduction records (Transitional), set sender's vector. _**(Take member lock)**_ + - Compiled list of recipients to be marked as introduced; differentiate 'MRIntroduced'/'MRIntroducedTo'? (Complication of splitting introduced into 2 relations). + - Additionally get Connected members, currently they are filtered out as not requiring forward. (It is necessary to make a complete computation of vector in one go, as this member will then be skipped in background updates) + +#### Avoid duplicate forwards + +N updates approach allows us to avoid duplicate forwards: + +- Admin only forwards based on introductions embedded into relations vector: 'MRIntroduced', 'MRIntroducedTo'. + +- Admin doesn't forward to 'MRNew' members. + +Following diagram illustrates that in multi-admin scenario only host of "later" invitee (Bob) will forward messages between his and other admin's invitees. + +```mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + participant C as Cath + +note over A, C: Alice invites and introduces Cath +A <<->> C: invite, CON +A ->> B: announce Cath +A ->> C: introduce Bob +note over A, C: Bob invites and introduces Dan +create participant D as Dan +B <<->> D: invite, CON +B ->> A: announce Dan +B ->> C: announce Dan +B ->> D: introduce Alice, Cath +note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avoid duplicate forwards) +note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew +note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo +note over A, B: Only Bob forwards between Cath and Dan +C ->> B: x.grp.mem.con (connected to Dan) +D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough) +note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected +note over A, B: Only Bob forwards between Cath and Dan +``` + +### Avoid duplicate introductions + +Scenario 1. Pending member is accepted to group -> avoid repeat introductions to moderators and above. + +Scenario 2. Two invitees connect to host concurrently -> avoid introductions race. + +Both can be solved by excluding already introduced members: +- Member (new invitee) has vector: filter out 'MRIntroduced', 'MRIntroducedTo' members from list of members to introduce. +- No vector: filter out based on introduction records (Transitional; `introduceToRemaining` + restore `checkInverseIntro` logic). + +### Live migration (Stage 1) + +Background process to set members' vectors based on introductions. + +Goes over members with NULL relation vector. Logic to determine relations is same as when setting sender's vector on forwarding. The latter is optimization -> faster migration of hot paths. _**(Take member locks)**_ + +TBC report when done - for directory service. Or we can track remaining member records with NULL vector. + +### Offline migration (Stage 2) + +TBC SQL to set relations vectors based on remaining introductions records. + +### Other considerations + +#### 1. Introductions race - missed introductions + +We may have identified race where some pairs of members may never become introduced to each other. It can occur if 2 hosts concurrently invite (announce) and introduce their respective invitees based to their respective local member lists. + +Consider such timeline: + + 1. Admin 1 invites Invitee 1. + + Invitee 1 connects to Admin 1 (CON). + + Admin 1 announces (x.grp.mem.new) Invitee 1 and introduces him to known members (Admin 1 hasn't seen Invitee 2). + + 2. Admin 2 invites Invitee 2. + + Invitee 2 connects to Admin 2 (CON). + + _Consider following scenario: Admin 2 hasn't received x.grp.mem.new for Invitee 1 from Admin 1._ Admin 2 announces (x.grp.mem.new) Invitee 2 and introduces him to known members (Admin 2 hasn't seen Invitee 1). + + 3. Both admins receive (with delay) opposite x.grp.mem.new -> both admins already made introductions before and consider opposite admin would introduce "new" member to their "older" invitee. + +This is status quo, this work will not improve it. + +We will revert change of admins making decision of introductions lists based purely on member index, which may have made such race more likely. Instead they will determine introductions lists as following: all current members minus already introduced members (see "Avoid duplicate introductions" section). + +#### 2. Double x.grp.mem.con notifications + +As alternative to N updates for introduced members, we considered redundant forwarding in multi-admin scenario and modifying user clients (2-stage release) to send x.grp.mem.con notifications to both own host and host of connected member. + +Not symmetrical: a "later" invitee doesn't know which member is the host of an "earlier" invitee. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs index 5c4251cc55..bf5308bcfd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -5,43 +5,6 @@ module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector wh import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- to do list: --- - directory migration --- - background process to set member_relations_vector based on group_member_intros --- - also set member_relations_vector on forward (recipient list for sender is known there) --- - take member locks when updating member_relations_vector --- - for duration of migration forwarding operates in 2 modes simultaneously: --- - if member_relations_vector is set, use it --- - otherwise, use existing logic based on group_member_intros --- - new invitees start with member_relations_vector = 0 for all existing (pre) members -> --- member_relations_vector immediately can be used when new invitee sends --- - pre members are not updated right away for new invitee, if their member_relations_vector is not set yet, --- as it will be costly to update them all at once; instead it will be set once background process processes them; --- also this means group_member_intros have to be maintained for them until then --- - GroupMember.memberStatusVector is Maybe to make this differentiation --- - user clients migration --- - once directory service migrates to new state, member_relations_vector can be updated in db migration --- as user clients wouldn't have as large group_member_intros --- - TBC migration SQL --- - alternative approach for member_relations_vector migration (both directory and user clients): --- - set to 0 for all existing members right away in sql migration --- (possibly limit to groups where user is admin or above, otherwise NULL) --- - means that initially after migration new messages will be forwarded to all members, --- however they will quickly report connected state via XGrpMemCon -> member_relations_vector will self-adjust --- - allows for simple migration path, with immediate switch from group_member_intros, --- avoids complexity of dual-mode forwarding during migration for directory / complex sql migration for user clients --- - rework forwarding logic to use member_relations_vector: --- - create new members with correct index_in_group = group's member_index + 1, --- maintain groups.member_index --- - when new invitee joins, set member_relations_vector to all 0 for them, update for pre members (set 0 for invitee's seq id) --- - on XGrpMemCon update bitvectors for sender and referenced member (set 1 for corresponding seq ids) --- - don't maintain group_member_intros (don't create, update status) --- - on forwarding, get recipients based on sender's member_relations_vector --- - for all 0s in bitvector, get members by index_in_group in corresponding positions --- - second use of group_member_intros is targeted introductions of knocking member to "remaining" members --- - has to be reworked to not rely on group_member_intros --- - set MRIntroduced status in member relations vector on first introduction, --- exclude previously introduced members on second introduction m20251117_member_relations_vector :: Query m20251117_member_relations_vector = [sql| From f76e994034934f01b3ed6bafe7eb99021f077f88 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:48:32 +0000 Subject: [PATCH 5/5] core: forward based on relations vector (#6464) * core: forward based on relations vector wip * fix introductions * fix forwarding tests * fix forwarding inside support scope * fix deduplication test * fix more tests * plans, api * live migration wip * enable tests * member locks * api * plans * fix for postgres * fix for postgres * rename predicate * rename predicate * optimize * refactor * fix * check * move part of migration to sql * plans * core: preserve detailed information in relation vectors (#6484) * core: relations vector live migrations; stage 2 migration sql (#6472) * rework forwarding in support scope * move operations inside transactions * set_member_vector_new_relation function * read vector ad-hoc * partition in transaction * fix postgres * postgres schema * api * plans * remove comment * lock before migration computation * refactor * simplify set relations * retreive only support scope members * fix * refactor * fix comment * enable tests * 1 second * for update * locks * fix mask * plans * fix * postgres --------- Co-authored-by: Evgeny Poberezkin --- .../src/Directory/Store/Migrate.hs | 2 +- bots/api/TYPES.md | 14 +- cabal.project | 2 +- .../2025-11-24-member-relations-vector.md | 3 +- .../types/typescript/src/types.ts | 26 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 22 + src/Simplex/Chat/Library/Internal.hs | 82 +-- src/Simplex/Chat/Library/Subscriber.hs | 129 ++--- src/Simplex/Chat/Mobile.hs | 4 +- src/Simplex/Chat/Options/Postgres.hs | 7 +- src/Simplex/Chat/Options/SQLite.hs | 13 +- src/Simplex/Chat/Store/Delivery.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 466 +++++++++++------- src/Simplex/Chat/Store/Postgres/Migrations.hs | 2 + .../M20251117_member_relations_vector.hs | 97 ++++ ...0251128_member_relations_vector_stage_2.hs | 45 ++ .../Store/Postgres/Migrations/chat_schema.sql | 74 +++ src/Simplex/Chat/Store/SQLite/Migrations.hs | 2 + .../M20251117_member_relations_vector.hs | 80 +++ ...0251128_member_relations_vector_stage_2.hs | 42 ++ .../SQLite/Migrations/chat_query_plans.txt | 240 +++++---- src/Simplex/Chat/Store/Shared.hs | 4 +- src/Simplex/Chat/Types/MemberRelations.hs | 124 ++++- src/Simplex/Chat/View.hs | 1 - tests/ChatTests/Groups.hs | 54 +- tests/MemberRelationsTests.hs | 201 +++++--- tests/SchemaDump.hs | 11 +- 30 files changed, 1260 insertions(+), 497 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index ad37eba6d7..e22f4ed470 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -46,7 +46,7 @@ runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = migrateDBSchema chatStore - (toDBOpts dbOptions chatSuffix False) + (toDBOpts dbOptions chatSuffix False []) (Just "sx_directory_migrations") directorySchemaMigrations MigrationConfig {confirm, backupPath = Nothing} diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 9140f3b164..62c5dff3b5 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -3349,6 +3349,14 @@ GroupMemberNotFound: - type: "groupMemberNotFound" - groupMemberId: int64 +GroupMemberNotFoundByIndex: +- type: "groupMemberNotFoundByIndex" +- groupMemberIndex: int64 + +MemberRelationsVectorNotFound: +- type: "memberRelationsVectorNotFound" +- groupMemberId: int64 + GroupHostMemberNotFound: - type: "groupHostMemberNotFound" - groupId: int64 @@ -3361,6 +3369,9 @@ MemberContactGroupMemberNotFound: - type: "memberContactGroupMemberNotFound" - contactId: int64 +InvalidMemberRelationUpdate: +- type: "invalidMemberRelationUpdate" + GroupWithoutUser: - type: "groupWithoutUser" @@ -3447,9 +3458,6 @@ PendingConnectionNotFound: - type: "pendingConnectionNotFound" - connId: int64 -IntroNotFound: -- type: "introNotFound" - UniqueID: - type: "uniqueID" diff --git a/cabal.project b/cabal.project index df5ac1ba24..9f8d821a2e 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3016b929b48d3116f8ee60169c06383ca57f78e0 + tag: 92a9579e6958026e42a45e32c0e62fca243b4b0f source-repository-package type: git diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md index c4e8cf7224..087541fda3 100644 --- a/docs/rfcs/2025-11-24-member-relations-vector.md +++ b/docs/rfcs/2025-11-24-member-relations-vector.md @@ -72,10 +72,11 @@ note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avo note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo note over A, B: Only Bob forwards between Cath and Dan +C <<->> D: connect C ->> B: x.grp.mem.con (connected to Dan) D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough) note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected -note over A, B: Only Bob forwards between Cath and Dan +note over A, B: Bob stops forwarding between Cath and Dan ``` ### Avoid duplicate introductions diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index e67eae39a0..5e3309238b 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -3735,9 +3735,12 @@ export type StoreError = | StoreError.GroupNotFoundByName | StoreError.GroupMemberNameNotFound | StoreError.GroupMemberNotFound + | StoreError.GroupMemberNotFoundByIndex + | StoreError.MemberRelationsVectorNotFound | StoreError.GroupHostMemberNotFound | StoreError.GroupMemberNotFoundByMemberId | StoreError.MemberContactGroupMemberNotFound + | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember | StoreError.GroupAlreadyJoined @@ -3761,7 +3764,6 @@ export type StoreError = | StoreError.ConnectionNotFoundById | StoreError.ConnectionNotFoundByMemberId | StoreError.PendingConnectionNotFound - | StoreError.IntroNotFound | StoreError.UniqueID | StoreError.LargeMsg | StoreError.InternalError @@ -3820,9 +3822,12 @@ export namespace StoreError { | "groupNotFoundByName" | "groupMemberNameNotFound" | "groupMemberNotFound" + | "groupMemberNotFoundByIndex" + | "memberRelationsVectorNotFound" | "groupHostMemberNotFound" | "groupMemberNotFoundByMemberId" | "memberContactGroupMemberNotFound" + | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" | "groupAlreadyJoined" @@ -3846,7 +3851,6 @@ export namespace StoreError { | "connectionNotFoundById" | "connectionNotFoundByMemberId" | "pendingConnectionNotFound" - | "introNotFound" | "uniqueID" | "largeMsg" | "internalError" @@ -3988,6 +3992,16 @@ export namespace StoreError { groupMemberId: number // int64 } + export interface GroupMemberNotFoundByIndex extends Interface { + type: "groupMemberNotFoundByIndex" + groupMemberIndex: number // int64 + } + + export interface MemberRelationsVectorNotFound extends Interface { + type: "memberRelationsVectorNotFound" + groupMemberId: number // int64 + } + export interface GroupHostMemberNotFound extends Interface { type: "groupHostMemberNotFound" groupId: number // int64 @@ -4003,6 +4017,10 @@ export namespace StoreError { contactId: number // int64 } + export interface InvalidMemberRelationUpdate extends Interface { + type: "invalidMemberRelationUpdate" + } + export interface GroupWithoutUser extends Interface { type: "groupWithoutUser" } @@ -4112,10 +4130,6 @@ export namespace StoreError { connId: number // int64 } - export interface IntroNotFound extends Interface { - type: "introNotFound" - } - export interface UniqueID extends Interface { type: "uniqueID" } diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7acb51e97c..406ef76cc5 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3016b929b48d3116f8ee60169c06383ca57f78e0" = "1gvv96w1ag8xh19an5j50isjw0yq1cbsywii5s1adaplr6fw34mb"; + "https://github.com/simplex-chat/simplexmq.git"."92a9579e6958026e42a45e32c0e62fca243b4b0f" = "0hacyi4hsbca55l4n2n4pcsmsfvh2jaw681j6106y5mkillmb3fv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 24ccf24ded..bc49fc0b45 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -123,6 +123,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector + Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 else exposed-modules: Simplex.Chat.Archive @@ -269,6 +270,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector + Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9b711c2b50..93132fb413 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -118,8 +118,8 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} createChatDatabase :: ChatDbOpts -> MigrationConfig -> IO (Either MigrationError ChatDatabase) createChatDatabase chatDbOpts migrationConfig = runExceptT $ do - chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) migrationConfig - agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) migrationConfig + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False chatDBFunctions) migrationConfig + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False []) migrationConfig pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 630cad4e70..7f4ee238b1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -167,6 +167,9 @@ startChatController mainApp enableSndFiles = do runExceptT (syncConnections' users) >>= \case Left e -> liftIO $ putStrLn $ "Error synchronizing connections: " <> show e Right _ -> pure () + runExceptT migrateMemberRelations >>= \case + Left e -> liftIO $ putStrLn $ "Error migrating member relations: " <> show e + Right _ -> pure () restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) @@ -178,6 +181,10 @@ startChatController mainApp enableSndFiles = do (userDiff, connDiff) <- withAgent (\a -> syncConnections a aUserIds connIds) withFastStore' setConnectionsSyncTs toView $ CEvtConnectionsDiff (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) + migrateMemberRelations = + when mainApp $ + whenM (withStore' hasMembersWithoutVector) $ + void $ forkIO runRelationsVectorMigration start s users = do a1 <- async agentSubscriber a2 <- @@ -4163,6 +4170,21 @@ agentSubscriber = do type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) +runRelationsVectorMigration :: CM () +runRelationsVectorMigration = do + liftIO $ threadDelay' 5000000 -- 5 seconds (initial delay) + migrateMembers + where + stepDelay = 1000000 -- 1 second + migrateMembers = flip catchAllErrors eToView $ do + lift waitChatStartedAndActivated + gmIds <- withStore' getGMsWithoutVectorIds + forM_ gmIds $ \gmId -> do + lift waitChatStartedAndActivated + withStore' (`migrateMemberRelationsVector'` gmId) `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay + unless (null gmIds) migrateMembers + cleanupManager :: CM () cleanupManager = do interval <- asks (cleanupManagerInterval . config) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 711a24c79e..523224c76d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -73,6 +73,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, shuffle) @@ -1024,65 +1025,84 @@ introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = - if (maxVersion (memberChatVRange m) >= groupKnockingVersion) + if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing void $ sendDirectMemberMessage mConn msg groupId modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs + let rcpModMs = filter shouldIntroduce modMs introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) + where + shouldIntroduce :: GroupMember -> Bool + shouldIntroduce mem = + memberCurrent mem + && groupMemberId' mem /= groupMemberId' m + && maxVersion (memberChatVRange mem) >= groupKnockingVersion introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do - members <- withStore' $ \db -> getGroupMembersForIntroduction db vr user gInfo m - let recipients = filter memberCurrent members + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + vector_ <- withStore' (`getMemberRelationsVector_` m) + let recipients = filter (shouldIntroduce vector_) members introduceMember vr user gInfo m recipients Nothing + where + shouldIntroduce :: Maybe ByteString -> GroupMember -> Bool + shouldIntroduce vector_ m' = + memberCurrent m' + && groupMemberId' m' /= groupMemberId' m + && maybe True (\v -> getRelation (indexInGroup m') v == MRNew) vector_ introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToRemaining vr user gInfo m = do - (members, introducedGMIds) <- - withStore' $ \db -> (,) <$> getGroupMembersForIntroduction db vr user gInfo m <*> getIntroducedGroupMemberIds db m - let recipients = filter (introduceMemP introducedGMIds) members + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + vector_ <- withStore' (`getMemberRelationsVector_` m) + recipients <- filterRecipients vector_ members introduceMember vr user gInfo m recipients Nothing where - introduceMemP introducedGMIds mem = - memberCurrent mem - && groupMemberId' mem `notElem` introducedGMIds - && groupMemberId' mem /= groupMemberId' m + filterRecipients :: Maybe ByteString -> [GroupMember] -> CM [GroupMember] + filterRecipients vector_ members = do + newRelation <- case vector_ of + Nothing -> do + introducedGMIds <- S.fromList <$> withStore' (`getIntroducedGroupMemberIds` m) + pure $ \m' -> groupMemberId' m' `S.notMember` introducedGMIds + Just vec -> pure $ \m' -> getRelation (indexInGroup m') vec == MRNew + pure $ filter (\m' -> groupMemberId' m' /= groupMemberId' m && memberCurrent m' && newRelation m') members introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" -introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do - void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo m) msgScope +introduceMember vr user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo toMember) msgScope sendIntroductions introduceToMembers where - sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m - shuffledIntros <- liftIO $ shuffleIntros intros - if m `supportsVersion` batchSendVersion + sendIntroductions reMembers = do + updateToMemberVector reMembers + reMembers' <- withStore' $ \db -> createIntrosOrUpdateVectors db vr reMembers toMember + shuffledReMembers <- liftIO $ shuffleMembers reMembers' + if toMember `supportsVersion` batchSendVersion then do - let events = map (memberIntro . reMember) shuffledIntros + let events = map memberIntro shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId - else forM_ shuffledIntros $ \intro -> - processIntro intro `catchAllErrors` eToView + else forM_ shuffledReMembers $ \reMember -> + void $ sendDirectMemberMessage conn (memberIntro reMember) groupId + updateToMemberVector :: [GroupMember] -> CM () + updateToMemberVector reMembers = do + let relations = map (\GroupMember {indexInGroup} -> (indexInGroup, (IDReferencedIntroduced, MRIntroduced))) reMembers + withStore' $ \db -> setMemberVectorNewRelations db toMember relations memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = let mInfo = memberInfo gInfo reMember mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions - shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] - shuffleIntros intros = do - let (admins, others) = partition isAdmin intros + shuffleMembers :: [GroupMember] -> IO [GroupMember] + shuffleMembers reMembers = do + let (admins, others) = partition isAdmin reMembers (admPics, admNoPics) = partition hasPicture admins (othPics, othNoPics) = partition hasPicture others mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] where - isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin - hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image - processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId - withStore' $ \db -> updateIntroStatus db introId GMIntroSent + isAdmin GroupMember {memberRole} = memberRole >= GRAdmin + hasPicture GroupMember {memberProfile = LocalProfile {image}} = isJust image userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks @@ -2047,8 +2067,8 @@ readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {c | otherwise = Nothing readyMemberConn GroupMember {activeConn = Nothing} = Nothing -sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () -sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do +sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe GroupMemberIntro -> CM () -> CM () +sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent intro_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) messageMember msg `catchAllErrors` eToView where @@ -2056,7 +2076,7 @@ sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} c messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSASendBatched conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver - MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId (introId <$> intro_) MSAForwarded -> pure () -- TODO ensure order - pending messages interleave with user input messages diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 5073da9c77..27f3a4f61c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,7 +28,7 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl') +import Data.List (find) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -62,6 +62,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Description (ValidFileDescription) @@ -2615,13 +2616,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do - introId <- withStore $ \db -> do - GroupMemberIntro {introId} <- getIntroduction db reMember m - liftIO $ updateIntroStatus db introId GMIntroInvReceived - pure introId - sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) (Just introId) $ - withStore' $ - \db -> updateIntroStatus db introId GMIntroInvForwarded + intro_ <- withStore' $ \db -> getIntroduction db reMember m + update intro_ GMIntroInvReceived + sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) intro_ $ + update intro_ GMIntroInvForwarded + where + update (Just GroupMemberIntro {introId}) status = withStore' $ \db -> updateIntroStatus db introId status + update Nothing _ = pure () _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () @@ -2715,45 +2716,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () - xGrpMemCon gInfo sendingMember memId = do - refMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId - case (memberCategory sendingMember, memberCategory refMember) of - (GCInviteeMember, GCInviteeMember) -> - withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case - Right intro -> inviteeXGrpMemCon intro - Left _ -> - withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" - (GCInviteeMember, _) -> - withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case - Right intro -> inviteeXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" - (_, GCInviteeMember) -> - withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introductiosupportn" - -- Note: we can allow XGrpMemCon to all member categories if we decide to support broader group forwarding, - -- deduplication (see saveGroupRcvMsg, saveGroupFwdRcvMsg) already supports sending XGrpMemCon - -- to any forwarding member, not only host/inviting member; - -- database would track all members connections then - -- (currently it's done via group_member_intros for introduced connections only) - _ -> - messageWarning "x.grp.mem.con: neither member is invitee" - where - inviteeXGrpMemCon :: GroupMemberIntro -> CM () - inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of - GMIntroReConnected -> updateStatus introId GMIntroConnected - GMIntroToConnected -> pure () - GMIntroConnected -> pure () - _ -> updateStatus introId GMIntroToConnected - forwardMemberXGrpMemCon :: GroupMemberIntro -> CM () - forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of - GMIntroToConnected -> updateStatus introId GMIntroConnected - GMIntroReConnected -> pure () - GMIntroConnected -> pure () - _ -> updateStatus introId GMIntroReConnected - updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status + xGrpMemCon gInfo sendingMem memId = do + refMem <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId + withStore' (`migrateMemberRelationsVector` sendingMem) + withStore' (`migrateMemberRelationsVector` refMem) + -- Updating vectors in separate transactions to avoid deadlocks. + withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected + withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages chatMsg msg brokerTs forwarded = do @@ -3238,10 +3207,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let moderatorFilter mem = - memberCurrent mem - && maxVersion (memberChatVRange mem) >= groupKnockingVersion - && Just (groupMemberId' mem) /= singleSenderGMId_ + let moderatorFilter m = + memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion + && Just (groupMemberId' m) /= singleSenderGMId_ modMs' = filter moderatorFilter modMs mems <- if Just scopeGMId == singleSenderGMId_ @@ -3255,42 +3224,30 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" Just singleSenderGMId -> do sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId - mems <- buildMemberList sender - unless (null mems) $ deliver body mems + ms <- buildMemberList sender + unless (null ms) $ deliver body ms where - buildMemberList sender = case jobScope of - DJSGroup {jobSpec} - | jobSpecImpliedPending jobSpec -> - filter memberCurrentOrPending <$> getAllIntroducedAndInvited - | otherwise -> - filter memberCurrent <$> getAllIntroducedAndInvited - DJSMemberSupport scopeGMId -> do - -- moderators introduced to this invited member - introducedModMs <- - if memberCategory sender == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedModerators db vr user sender - else pure [] - -- invited moderators to which this member was introduced - invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user sender - let modMs = introducedModMs <> invitedModMs - modMs' = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs - if scopeGMId == groupMemberId' sender - then pure modMs' - else - withStore' (\db -> getForwardScopeMember db vr user sender scopeGMId) >>= \case - Just scopeMem -> pure $ scopeMem : modMs' - _ -> pure modMs' - where - getAllIntroducedAndInvited = do - ChatConfig {highlyAvailable} <- asks config - -- members introduced to this invited member - introducedMembers <- - if memberCategory sender == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db vr user sender highlyAvailable - else pure [] - -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user sender highlyAvailable - pure $ introducedMembers <> invitedMembers + buildMemberList sender = do + vec <- withStore $ \db -> migrateGetMemberRelationsVector db sender + -- this excludes the sender + let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec + case jobScope of + DJSGroup {jobSpec} -> do + ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m + | jobSpecImpliedPending jobSpec = memberCurrentOrPending m + | otherwise = memberCurrent m + DJSMemberSupport scopeGMId -> do + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m + currentModerator m@GroupMember {memberRole} = + memberRole >= GRModerator + && memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion where deliver :: ByteString -> [GroupMember] -> CM () deliver msgBody mems = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b22cfebcdd..3c8f170b0d 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -295,8 +295,8 @@ chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigra chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") - chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) migrationConfig - agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) migrationConfig + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where opts = mobileChatOpts $ removeDbKey chatDbOpts diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index 13af13b20a..c74ae37750 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -58,8 +58,8 @@ migrationBackupPathP = pure Nothing dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr -toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey = +toDBOpts :: ChatDbOpts -> String -> Bool -> [()] -> DBOpts +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey _dbFunctions = DBOpts { connstr = B.pack dbConnstr, schema = B.pack $ if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix, @@ -73,6 +73,9 @@ chatSuffix = "_chat_schema" agentSuffix :: String agentSuffix = "_agent_schema" +chatDBFunctions :: [()] +chatDBFunctions = [] + mobileDbOpts :: CString -> CString -> IO ChatDbOpts mobileDbOpts schemaPrefix connstr = do dbSchemaPrefix <- peekCString schemaPrefix diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 8591f0801c..7e25bb2217 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -11,7 +11,9 @@ import qualified Data.ByteArray as BA import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative +import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import Simplex.Messaging.Agent.Store.SQLite.Common (SQLiteFuncDef (..), SQLiteFuncPtrs (..)) import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..)) import System.FilePath (combine) @@ -70,10 +72,11 @@ migrationBackupPathP = dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" -toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do +toDBOpts :: ChatDbOpts -> String -> Bool -> [SQLiteFuncDef] -> DBOpts +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey dbFunctions = do DBOpts { dbFilePath = dbFilePrefix <> dbSuffix, + dbFunctions, dbKey, keepKey, vacuum = vacuumOnMigration, @@ -86,6 +89,12 @@ chatSuffix = "_chat.db" agentSuffix :: String agentSuffix = "_agent.db" +chatDBFunctions :: [SQLiteFuncDef] +chatDBFunctions = + [ SQLiteFuncDef "migrate_relations_vector" 3 (SQLiteAggrPtrs sqliteMemberRelationsStepPtr sqliteMemberRelationsFinalPtr), + SQLiteFuncDef "set_member_vector_new_relation" 4 (SQLiteFuncPtr True sqliteSetMemberVectorNewRelationPtr) + ] + mobileDbOpts :: CString -> CString -> IO ChatDbOpts mobileDbOpts fp key = do dbFilePrefix <- peekCString fp diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 30fffac0e7..c1da436f04 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -185,7 +185,7 @@ getNextDeliveryTasks db gInfo task = | otherwise = -- For fully connected groups we guarantee a singleSenderGMId for a delivery job by additionally filtering -- on sender_group_member_id here, so that the job can then retrieve less members as recipients, - -- optimizing for this single sender (see processDeliveryJob -> getForwardIntroducedMembers, etc.). + -- optimizing for this single sender (see processDeliveryJob -> fully connected group branch). -- We do this optimization in the job to decrease load on admins using mobile devices for clients. map fromOnly <$> DB.query diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c04a31c0a9..44dcd3c7f9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -58,11 +58,13 @@ module Simplex.Chat.Store.Groups getMentionedGroupMember, getMentionedMemberByMemberId, getGroupMemberById, + getGroupMemberByIndex, getGroupMemberByMemberId, getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, - getGroupMembersForIntroduction, + getGroupMembersByIndexes, + getSupportScopeMembersByIndexes, getGroupModerators, getGroupRelays, getGroupMembersForExpiration, @@ -98,14 +100,17 @@ module Simplex.Chat.Store.Groups deleteGroupMemberConnection, updateGroupMemberRole, createIntroductions, + createIntrosOrUpdateVectors, + setMemberVectorNewRelations, + setMembersVectorsNewRelation, + setMemberVectorRelationConnected, + migrateGetMemberRelationsVector, + migrateMemberRelationsVector, + migrateMemberRelationsVector', + getMemberRelationsVector_, updateIntroStatus, getIntroduction, getIntroducedGroupMemberIds, - getForwardIntroducedMembers, - getForwardIntroducedModerators, - getForwardInvitedMembers, - getForwardInvitedModerators, - getForwardScopeMember, createIntroReMember, createIntroToMemberContact, getMatchingContacts, @@ -146,6 +151,8 @@ module Simplex.Chat.Store.Groups setGroupChatTTL, getGroupChatTTL, getUserGroupsToExpire, + hasMembersWithoutVector, + getGMsWithoutVectorIds, updateGroupAlias, ) where @@ -155,8 +162,11 @@ import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Bifunctor (second) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B import Data.Char (toLower) import Data.Either (rights) +import Data.Foldable (foldrM) import Data.Int (Int64) import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) @@ -170,6 +180,7 @@ import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), setNewRelations, setRelationConnected, toIntroDirInt, toRelationInt) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -180,11 +191,12 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import qualified Data.Set as S +import Database.PostgreSQL.Simple (In (..), Only (..), Query, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else import Database.SQLite.Simple (Only (..), Query, (:.) (..)) @@ -511,12 +523,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) :. (minV, maxV) ) @@ -530,12 +542,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) :. (minV, maxV) ) @@ -570,11 +582,11 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -766,11 +778,11 @@ createGroupViaLink' db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -994,6 +1006,22 @@ getGroupMemberById db vr user@User {userId} groupMemberId = (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) +getGroupMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember +getGroupMemberByIndex db vr user GroupInfo {groupId} indexInGroup = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") + (groupId, indexInGroup) + +getSupportScopeMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember +getSupportScopeMemberByIndex db vr user GroupInfo {groupId} scopeGMId indexInGroup = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") + (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) + getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ @@ -1017,20 +1045,38 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do +getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) -getGroupMembersForIntroduction :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> IO [GroupMember] -getGroupMembersForIntroduction db vr user@User {userId, userContactId} GroupInfo {groupId} _introduced@GroupMember {indexInGroup} = do - map (toContactMember vr user) - <$> DB.query +getGroupMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> [Int64] -> IO [GroupMember] +getGroupMembersByIndexes db vr user gInfo indexesInGroup = do +#if defined(dbPostgres) + let GroupInfo {groupId} = gInfo + map (toContactMember vr user) <$> + DB.query db - (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ?") - (userId, groupId, userContactId, indexInGroup) + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") + (groupId, In indexesInGroup) +#else + rights <$> mapM (runExceptT . getGroupMemberByIndex db vr user gInfo) indexesInGroup +#endif + +getSupportScopeMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] +getSupportScopeMembersByIndexes db vr user gInfo scopeGMId indexesInGroup = do +#if defined(dbPostgres) + let GroupInfo {groupId} = gInfo + map (toContactMember vr user) <$> + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") + (groupId, In indexesInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) +#else + rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db vr user gInfo scopeGMId) indexesInGroup +#endif getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1141,12 +1187,12 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) :. (minV, maxV) ) @@ -1169,12 +1215,12 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) :. (minV, maxV) ) @@ -1212,12 +1258,12 @@ createJoiningMember db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) :. (minV, maxV) ) @@ -1291,12 +1337,12 @@ createBusinessRequestGroup db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -1497,12 +1543,12 @@ createNewMember_ db [sql| INSERT INTO group_members - (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, memRestriction, invitedById, memInvitedByGroupMemberId) :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) :. (minV, maxV) ) @@ -1561,27 +1607,196 @@ updateGroupMemberRole :: DB.Connection -> User -> GroupMember -> GroupMemberRole updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = DB.execute db "UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?" (memRole, userId, groupMemberId) -createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] -createIntroductions db chatV members toMember = do - let reMembers = filter (\m -> memberCurrent m && groupMemberId' m /= groupMemberId' toMember) members - if null reMembers - then pure [] - else do +createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMember] +createIntroductions db chatV reMembers toMember + | null reMembers = pure [] + | otherwise = do currentTs <- getCurrentTime - mapM (insertIntro_ currentTs) reMembers + catMaybes <$> mapM (createIntro_ currentTs) reMembers where - insertIntro_ :: UTCTime -> GroupMember -> IO GroupMemberIntro - insertIntro_ ts reMember = do - DB.execute + createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMember) + createIntro_ ts reMember = + -- when members connect concurrently, host would try to create introductions between them in both directions; + -- this check avoids creating second (redundant) introduction + checkInverseIntro >>= \case + Just _ -> pure Nothing + Nothing -> do + DB.execute + db + [sql| + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) + pure $ Just reMember + where + checkInverseIntro :: IO (Maybe Int64) + checkInverseIntro = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1" + (groupMemberId' toMember, groupMemberId' reMember) + +-- Create introductions for members without vectors and update vectors for members with vectors. +-- Partitioning and updates happen in same transaction to avoid race conditions. +createIntrosOrUpdateVectors :: DB.Connection -> VersionRangeChat -> [GroupMember] -> GroupMember -> IO [GroupMember] +createIntrosOrUpdateVectors db vr reMembers toMember + | null reMembers = pure [] + | otherwise = do + (memsWithVec, memsWithoutVec) <- partitionByVector reMembers + let GroupMember {indexInGroup} = toMember + setMembersVectorsNewRelation db memsWithVec indexInGroup IDSubjectIntroduced MRIntroduced + memsWithoutVec' <- createIntroductions db (maxVersion vr) memsWithoutVec toMember + pure $ memsWithoutVec' <> memsWithVec + where + partitionByVector :: [GroupMember] -> IO ([GroupMember], [GroupMember]) +#if defined(dbPostgres) + partitionByVector members = do + let memberIds = map groupMemberId' members + -- Lock rows first to ensure partitioning doesn't change in case of concurrent updates + _ :: [Only Int] <- + DB.query + db + "SELECT 1 FROM group_members WHERE group_member_id IN ? FOR UPDATE" + (Only $ In memberIds) + memberIdsWithVec <- S.fromList . map fromOnly <$> + DB.query + db + "SELECT group_member_id FROM group_members WHERE group_member_id IN ? AND member_relations_vector IS NOT NULL" + (Only $ In memberIds) + pure $ partition (\m -> groupMemberId' m `S.member` memberIdsWithVec) members +#else + partitionByVector = foldrM checkMember ([], []) + where + checkMember m (withVec, withoutVec) = do + hasVec <- isJust <$> maybeFirstRow fromOnly + (DB.query db "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" (Only $ groupMemberId' m) :: IO [Only Int64]) + pure $ if hasVec then (m : withVec, withoutVec) else (withVec, m : withoutVec) +#endif + +setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO () +setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do + v_ <- maybeFirstRow fromOnly $ + DB.query + db +#if defined(dbPostgres) + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? FOR UPDATE" +#else + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" +#endif + (Only groupMemberId) + let v' = setNewRelations relations $ fromMaybe B.empty v_ + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? + |] + (Binary v', currentTs, groupMemberId) + +setMembersVectorsNewRelation :: DB.Connection -> [GroupMember] -> Int64 -> IntroductionDirection -> MemberRelation -> IO () +setMembersVectorsNewRelation db members idx dir status = do + currentTs <- getCurrentTime +#if defined(dbPostgres) + let memberIds = map groupMemberId' members + DB.execute + db + "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id IN ?" + (idx, toIntroDirInt dir, toRelationInt status, currentTs, In memberIds) +#else + forM_ members $ \GroupMember {groupMemberId} -> + DB.execute + db + "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ?" + (idx, toIntroDirInt dir, toRelationInt status, currentTs, groupMemberId) +#endif + +setMemberVectorRelationConnected :: DB.Connection -> GroupMember -> GroupMember -> MemberRelation -> ExceptT StoreError IO () +setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {indexInGroup} newStatus = do + when (newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected) $ + throwError SEInvalidMemberRelationUpdate + v <- ExceptT $ + firstRow fromOnly (SEMemberRelationsVectorNotFound groupMemberId) $ + DB.query db - [sql| - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - |] - (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) - introId <- insertedRowId db - pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} +#if defined(dbPostgres) + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL FOR UPDATE" +#else + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" +#endif + (Only groupMemberId) + let v' = setRelationConnected indexInGroup newStatus v + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute + db + [sql| + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? + |] + (Binary v', currentTs, groupMemberId) + +migrateGetMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString +migrateGetMemberRelationsVector db m@GroupMember {groupMemberId} = do + liftIO $ migrateMemberRelationsVector db m + ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $ + DB.query + db + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" + (Only groupMemberId) + +migrateMemberRelationsVector :: DB.Connection -> GroupMember -> IO () +migrateMemberRelationsVector db GroupMember {groupMemberId} = + migrateMemberRelationsVector' db groupMemberId + +migrateMemberRelationsVector' :: DB.Connection -> GroupMemberId -> IO () +migrateMemberRelationsVector' db groupMemberId = do + currentTs <- liftIO getCurrentTime + liftIO $ do +#if defined(dbPostgres) + -- Lock the row first to ensure computation runs only after lock is acquired + _ :: [Only Int] <- + DB.query + db + "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NULL FOR UPDATE" + (Only groupMemberId) +#endif + DB.execute + db + [sql| + UPDATE group_members + SET + member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations + ), + updated_at = ? + WHERE group_member_id = ? + AND member_relations_vector IS NULL + |] + (currentTs, groupMemberId) + +getMemberRelationsVector_ :: DB.Connection -> GroupMember -> IO (Maybe ByteString) +getMemberRelationsVector_ db GroupMember {groupMemberId} = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" + (Only groupMemberId) updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do @@ -1595,9 +1810,9 @@ updateIntroStatus db introId introStatus = do |] (introStatus, currentTs, introId) -getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction db reMember toMember = ExceptT $ - firstRow toIntro SEIntroNotFound $ +getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> IO (Maybe GroupMemberIntro) +getIntroduction db reMember toMember = + maybeFirstRow toIntro $ DB.query db [sql| @@ -1619,106 +1834,6 @@ getIntroducedGroupMemberIds db invitee = "SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?" (Only $ groupMemberId' invitee) -getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardIntroducedMembers db vr user invitee highlyAvailable = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' invitee - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) - q = - [sql| - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] - --- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client, --- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardIntroducedMembers) -getForwardIntroducedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember] -getForwardIntroducedModerators db vr user@User {userContactId} invitee = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' invitee - query = - DB.query - db - [sql| - SELECT i.re_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.re_group_member_id - WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - |] - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner) - -getForwardInvitedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardInvitedMembers db vr user forwardMember highlyAvailable = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' forwardMember - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) - q = - [sql| - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] - --- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client, --- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardInvitedMembers) -getForwardInvitedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember] -getForwardInvitedModerators db vr user@User {userContactId} forwardMember = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' forwardMember - query = - DB.query - db - [sql| - SELECT i.to_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.to_group_member_id - WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - |] - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner) - -getForwardScopeMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMemberId -> IO (Maybe GroupMember) -getForwardScopeMember db vr user GroupMember {groupMemberId = sendingGMId} scopeGMId = do - (introExists_ :: Maybe Int64) <- - liftIO $ maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT 1 - FROM group_member_intros - WHERE - ( - (re_group_member_id = ? AND to_group_member_id = ?) OR - (re_group_member_id = ? AND to_group_member_id = ?) - ) - AND intro_status NOT IN (?,?,?) - LIMIT 1 - |] - (sendingGMId, scopeGMId, scopeGMId, sendingGMId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - pure introExists_ $>> (eitherToMaybe <$> runExceptT (getGroupMemberById db vr user scopeGMId)) - createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db @@ -2505,12 +2620,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -2608,6 +2723,25 @@ getUserGroupsToExpire db User {userId} globalTTL = where cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" +hasMembersWithoutVector :: DB.Connection -> IO Bool +hasMembersWithoutVector db = + fromOnly . head + <$> DB.query_ + db + "SELECT EXISTS (SELECT 1 FROM group_members WHERE member_relations_vector IS NULL LIMIT 1)" + +getGMsWithoutVectorIds :: DB.Connection -> IO [GroupMemberId] +getGMsWithoutVectorIds db = + map fromOnly <$> + DB.query_ + db + [sql| + SELECT group_member_id + FROM group_members + WHERE member_relations_vector IS NULL + LIMIT 1000 + |] + updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 9361713ea2..9dd388be0a 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -22,6 +22,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connection import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector +-- import Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -44,6 +45,7 @@ schemaMigrations = ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) + -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs index 3f6deeb80f..33583cc076 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -6,10 +6,91 @@ import Data.Text (Text) import qualified Data.Text as T import Text.RawString.QQ (r) +-- This migration creates custom aggregate function migrate_relations_vector(idx, direction, intro_status). +-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2). +-- +-- Vector byte encoding: 4 reserved | 1 direction | 3 status +-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced +-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected +-- +-- The aggregate transforms intro_status into relation status: +-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1) +-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3) +-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2) +-- - intro_status 'con': MRConnected (4) +-- +-- Final byte combines direction and status: byte = (direction << 3) | status + m20251117_member_relations_vector :: Text m20251117_member_relations_vector = T.pack [r| +CREATE FUNCTION set_member_vector_new_relation(v BYTEA, idx BIGINT, direction INT, status INT) +RETURNS BYTEA AS $$ +DECLARE + new_len INT; + result BYTEA; + byte_val INT; + old_byte INT; +BEGIN + IF idx < 0 THEN + RETURN v; + END IF; + IF idx < length(v) THEN + old_byte := get_byte(v, idx::INT); + ELSE + old_byte := 0; + END IF; + byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status; + new_len := GREATEST(length(v), idx + 1); + IF new_len > length(v) THEN + result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v))); + ELSE + result := v; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE FUNCTION migrate_relations_vector_step(state BYTEA, idx BIGINT, direction INT, intro_status TEXT) +RETURNS BYTEA AS $$ +DECLARE + new_len INT; + result BYTEA; + status INT; + byte_val INT; +BEGIN + IF idx < 0 THEN + RETURN state; + END IF; + IF intro_status = 're-con' THEN + IF direction = 0 THEN status := 2; ELSE status := 3; END IF; + ELSIF intro_status = 'to-con' THEN + IF direction = 0 THEN status := 3; ELSE status := 2; END IF; + ELSIF intro_status = 'con' THEN + status := 4; + ELSE + status := 1; + END IF; + byte_val := (direction * 8) + status; + new_len := GREATEST(length(state), idx + 1); + IF new_len > length(state) THEN + result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state))); + ELSE + result := state; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT) ( + SFUNC = migrate_relations_vector_step, + STYPE = BYTEA, + INITCOND = '' +); + ALTER TABLE group_members ADD COLUMN index_in_group BIGINT NOT NULL DEFAULT 0; ALTER TABLE groups ADD COLUMN member_index BIGINT NOT NULL DEFAULT 0; @@ -46,12 +127,28 @@ SET member_index = COALESCE(( FROM group_members WHERE group_members.group_id = g.group_id ), 0); + +UPDATE group_members +SET member_relations_vector = ''::BYTEA +WHERE group_id IN ( + SELECT mu.group_id + FROM group_members mu + WHERE mu.member_category = 'user' + AND ( + mu.member_role NOT IN ('admin', 'owner') + OR mu.member_status IN ('removed', 'left', 'deleted') + ) +); |] down_m20251117_member_relations_vector :: Text down_m20251117_member_relations_vector = T.pack [r| +DROP AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT); +DROP FUNCTION migrate_relations_vector_step(BYTEA, BIGINT, INT, TEXT); +DROP FUNCTION set_member_vector_new_relation(BYTEA, BIGINT, INT, INT); + DROP INDEX idx_group_members_group_id_index_in_group; ALTER TABLE group_members DROP COLUMN index_in_group; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs new file mode 100644 index 0000000000..7cfc273d62 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +-- Build member_relations_vector for all members that don't have it yet. +-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector. +-- +-- Query returns (idx, direction, intro_status) for each introduction: +-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member +-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it + +-- TODO [relations vector] drop group_member_intros in the end of migration +m20251128_member_relations_vector_stage_2 :: Text +m20251128_member_relations_vector_stage_2 = + T.pack + [r| +UPDATE group_members +SET member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations +) +WHERE member_relations_vector IS NULL; +|] + +-- TODO [relations vector] re-create group_member_intros +down_m20251128_member_relations_vector_stage_2 :: Text +down_m20251128_member_relations_vector_stage_2 = + T.pack + [r| + +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 712099d7c9..567aca0144 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -34,6 +34,41 @@ $$; +CREATE FUNCTION test_chat_schema.migrate_relations_vector_step(state bytea, idx bigint, direction integer, intro_status text) RETURNS bytea + LANGUAGE plpgsql IMMUTABLE + AS $$ +DECLARE + new_len INT; + result BYTEA; + status INT; + byte_val INT; +BEGIN + IF idx < 0 THEN + RETURN state; + END IF; + IF intro_status = 're-con' THEN + IF direction = 0 THEN status := 2; ELSE status := 3; END IF; + ELSIF intro_status = 'to-con' THEN + IF direction = 0 THEN status := 3; ELSE status := 2; END IF; + ELSIF intro_status = 'con' THEN + status := 4; + ELSE + status := 1; + END IF; + byte_val := (direction * 8) + status; + new_len := GREATEST(length(state), idx + 1); + IF new_len > length(state) THEN + result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state))); + ELSE + result := state; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$; + + + CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -85,6 +120,45 @@ END; $$; + +CREATE FUNCTION test_chat_schema.set_member_vector_new_relation(v bytea, idx bigint, direction integer, status integer) RETURNS bytea + LANGUAGE plpgsql IMMUTABLE + AS $$ +DECLARE + new_len INT; + result BYTEA; + byte_val INT; + old_byte INT; +BEGIN + IF idx < 0 THEN + RETURN v; + END IF; + IF idx < length(v) THEN + old_byte := get_byte(v, idx::INT); + ELSE + old_byte := 0; + END IF; + byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status; + new_len := GREATEST(length(v), idx + 1); + IF new_len > length(v) THEN + result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v))); + ELSE + result := v; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$; + + + +CREATE AGGREGATE test_chat_schema.migrate_relations_vector(bigint, integer, text) ( + SFUNC = test_chat_schema.migrate_relations_vector_step, + STYPE = bytea, + INITCOND = '' +); + + SET default_table_access_method = heap; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 13ada872b2..0358ae621d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -145,6 +145,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector +-- import Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -290,6 +291,7 @@ schemaMigrations = ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) + -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs index bf5308bcfd..3e4b4157f0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -1,9 +1,77 @@ +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector where +import qualified Data.ByteString as B import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) +import Database.SQLite3 (funcArgBlob, funcArgInt64, funcArgText, funcResultBlob) +import Database.SQLite3.Bindings +import Foreign.C.Types +import Foreign.Ptr +import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), fromIntroDirInt, fromRelationInt, setNewRelation, setNewRelations) +import Simplex.Messaging.Agent.Store.SQLite.Util (SQLiteFunc, SQLiteFuncFinal, mkSQLiteAggFinal, mkSQLiteAggStep, mkSQLiteFunc) + +-- This module defines custom aggregate function migrate_relations_vector(idx, direction, intro_status). +-- It is passed via DBOpts and registered on DB open. +-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2). +-- +-- Vector byte encoding: 4 reserved | 1 direction | 3 status +-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced +-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected +-- +-- The aggregate transforms intro_status into relation status: +-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1) +-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3) +-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2) +-- - intro_status 'con': MRConnected (4) +-- +-- The final function builds the vector using setNewRelations. + +foreign export ccall "simplex_member_relations_step" sqliteMemberRelationsStep :: SQLiteFunc + +foreign import ccall "&simplex_member_relations_step" sqliteMemberRelationsStepPtr :: FunPtr SQLiteFunc + +foreign export ccall "simplex_member_relations_final" sqliteMemberRelationsFinal :: SQLiteFuncFinal + +foreign import ccall "&simplex_member_relations_final" sqliteMemberRelationsFinalPtr :: FunPtr SQLiteFuncFinal + +-- Step function for migrate_relations_vector aggregate. +-- Accumulates (idx, direction, relation) tuples. +sqliteMemberRelationsStep :: SQLiteFunc +sqliteMemberRelationsStep = mkSQLiteAggStep [] $ \_ args acc -> do + idx <- funcArgInt64 args 0 + direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 1 + introStatus <- funcArgText args 2 + let relation = introStatusToRelation direction introStatus + pure $ (idx, (direction, relation)) : acc + where + introStatusToRelation dir status = case status of + "re-con" -> if dir == IDSubjectIntroduced then MRSubjectConnected else MRReferencedConnected + "to-con" -> if dir == IDSubjectIntroduced then MRReferencedConnected else MRSubjectConnected + "con" -> MRConnected + _ -> MRIntroduced -- 'new', 'sent', 'rcv', 'fwd' + +-- Final function for migrate_relations_vector aggregate. +-- Builds the vector from accumulated tuples using setNewRelations. +sqliteMemberRelationsFinal :: SQLiteFuncFinal +sqliteMemberRelationsFinal = mkSQLiteAggFinal [] $ \cxt acc -> funcResultBlob cxt $ setNewRelations acc B.empty + +-- Non-aggregate function set_member_vector_new_relation(vector, idx, direction, status). +-- Sets a new relation in the vector and returns the updated vector. + +foreign export ccall "simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelation :: SQLiteFunc + +foreign import ccall "&simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelationPtr :: FunPtr SQLiteFunc + +sqliteSetMemberVectorNewRelation :: SQLiteFunc +sqliteSetMemberVectorNewRelation = mkSQLiteFunc $ \cxt args -> do + v <- funcArgBlob args 0 + idx <- funcArgInt64 args 1 + direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 2 + status <- fromRelationInt . fromIntegral <$> funcArgInt64 args 3 + funcResultBlob cxt $ setNewRelation idx direction status v m20251117_member_relations_vector :: Query m20251117_member_relations_vector = @@ -46,6 +114,18 @@ SET member_index = COALESCE(( FROM group_members WHERE group_members.group_id = g.group_id ), 0); + +UPDATE group_members +SET member_relations_vector = x'' +WHERE group_id IN ( + SELECT mu.group_id + FROM group_members mu + WHERE mu.member_category = 'user' + AND ( + mu.member_role NOT IN (CAST('admin' AS BLOB), CAST('owner' AS BLOB)) + OR mu.member_status IN ('removed', 'left', 'deleted') + ) +); |] down_m20251117_member_relations_vector :: Query diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs new file mode 100644 index 0000000000..f510a50410 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- Build member_relations_vector for all members that don't have it yet. +-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector. +-- +-- Query returns (idx, direction, intro_status) for each introduction: +-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member +-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it + +-- TODO [relations vector] drop group_member_intros in the end of migration +m20251128_member_relations_vector_stage_2 :: Query +m20251128_member_relations_vector_stage_2 = + [sql| +UPDATE group_members +SET member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) +) +WHERE member_relations_vector IS NULL; +|] + +-- TODO [relations vector] re-create group_member_intros +down_m20251128_member_relations_vector_stage_2 :: Query +down_m20251128_member_relations_vector_stage_2 = + [sql| + +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 9156b56fcb..c46f6a5e7d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -24,10 +24,10 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -76,10 +76,10 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -260,9 +260,9 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -293,10 +293,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -327,10 +327,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -492,9 +492,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -525,10 +525,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -989,19 +989,12 @@ Query: Plan: -Query: - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - -Plan: - Query: INSERT INTO group_members - (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -1191,24 +1184,6 @@ SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_con SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT 1 - FROM group_member_intros - WHERE - ( - (re_group_member_id = ? AND to_group_member_id = ?) OR - (re_group_member_id = ? AND to_group_member_id = ?) - ) - AND intro_status NOT IN (?,?,?) - LIMIT 1 - -Plan: -MULTI-INDEX OR -INDEX 1 -SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) -INDEX 2 -SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) - Query: SELECT 1 FROM users WHERE (user_id = ? AND local_display_name = ?) @@ -1455,28 +1430,6 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT i.re_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.re_group_member_id - WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - -Plan: -SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT i.to_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.to_group_member_id - WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - -Plan: -SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT member_status FROM group_members @@ -1645,10 +1598,10 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -3509,6 +3462,14 @@ Plan: SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + +Plan: +SCAN group_members + Query: SELECT m.group_member_id FROM group_members m @@ -3559,14 +3520,6 @@ SEARCH r USING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -Query: - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - AND intro_chat_protocol_version >= ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) - Query: SELECT reaction FROM chat_item_reactions @@ -3655,14 +3608,6 @@ Query: Plan: SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?) -Query: - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - AND intro_chat_protocol_version >= ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) - Query: SELECT usage_conditions_id, conditions_commit, notified_at, created_at FROM usage_conditions @@ -3782,6 +3727,40 @@ SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_ LIST SUBQUERY 2 SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +Query: + UPDATE group_members + SET + member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations + ), + updated_at = ? + WHERE group_member_id = ? + AND member_relations_vector IS NULL + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 3 +CO-ROUTINE relations +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +UNION ALL +SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SCAN relations + Query: UPDATE group_members SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -3790,6 +3769,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + +Plan: +SCAN group_members + Query: UPDATE groups SET member_index = member_index + 1 @@ -4739,12 +4726,12 @@ Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) Query: - UPDATE group_member_intros - SET intro_status = ?, updated_at = ? - WHERE group_member_intro_id = ? + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? Plan: -SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_members @@ -5133,6 +5120,44 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.index_in_group = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -5209,25 +5234,6 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN -Query: - SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.group_member_id = m.group_member_id - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ? -Plan: -SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group Word8 +toIntroDirInt = \case + IDSubjectIntroduced -> 0 + IDReferencedIntroduced -> 1 + +fromIntroDirInt :: Word8 -> IntroductionDirection +fromIntroDirInt = \case + 0 -> IDSubjectIntroduced + 1 -> IDReferencedIntroduced + _ -> IDSubjectIntroduced + data MemberRelation = MRNew | MRIntroduced - | MRConnected - deriving (Eq, Show) + | MRSubjectConnected -- Subject member notified about connection to referenced member + | MRReferencedConnected -- Referenced member notified about connection to subject member + | MRConnected -- Both members notified about connection + deriving (Eq, Ord, Show) toRelationInt :: MemberRelation -> Word8 toRelationInt = \case MRNew -> 0 MRIntroduced -> 1 - MRConnected -> 2 + MRSubjectConnected -> 2 + MRReferencedConnected -> 3 + MRConnected -> 4 fromRelationInt :: Word8 -> MemberRelation fromRelationInt = \case 0 -> MRNew 1 -> MRIntroduced - 2 -> MRConnected + 2 -> MRSubjectConnected + 3 -> MRReferencedConnected + 4 -> MRConnected _ -> MRNew +-- Bit layout: 4 reserved | 1 direction | 3 status + -- | Get the relation status of a member at a given index from the relations vector. -- Returns 'MRNew' if the vector is not long enough (lazy initialization). getRelation :: Int64 -> ByteString -> MemberRelation -getRelation i v - | i < 0 || fromIntegral i >= B.length v = MRNew - | otherwise = fromRelationInt $ (v `B.index` fromIntegral i) .&. relationMask +getRelation i v = snd $ getRelation' i v +-- | Get both direction and status of a member at a given index from the relations vector. +-- Returns (IDSubjectIntroduced, MRNew) if the vector is not long enough (lazy initialization). +getRelation' :: Int64 -> ByteString -> (IntroductionDirection, MemberRelation) +getRelation' i v + | i < 0 || fromIntegral i >= B.length v = (IDSubjectIntroduced, MRNew) + | otherwise = + let b = v `B.index` fromIntegral i + in (fromIntroDirInt $ (b .&. directionMask) `shiftR` 3, fromRelationInt $ b .&. statusMask) + +-- | Get the indexes of members with the given relation status from the relations vector. +getRelationsIndexes :: MemberRelation -> ByteString -> [Int64] +getRelationsIndexes r v = [i | i <- [0 .. fromIntegral (B.length v) - 1], getRelation i v == r] -- | Set the relation status of a member at a given index in the relations vector. --- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +-- Preserves the introduction direction. Expands the vector lazily if needed. setRelation :: Int64 -> MemberRelation -> ByteString -> ByteString setRelation i r v | i >= 0 = setRelations [(i, r)] v | otherwise = v --- | Set multiple relations at once. --- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +-- | Set multiple relation statuses at once. +-- Preserves the introduction direction. Expands the vector lazily if needed. setRelations :: [(Int64, MemberRelation)] -> ByteString -> ByteString -setRelations [] v = v -setRelations relations v = +setRelations = setRelations_ $ \r b -> (b .&. complement statusMask) .|. toRelationInt r + +-- | Set relation to connected state based on passed status and current status. +-- newStatus should be MRSubjectConnected or MRReferencedConnected, otherwise returns vector unchanged. +-- Logic: +-- - if newStatus is complementary to oldStatus -> set MRConnected +-- - if newStatus > oldStatus (by enum order) -> set newStatus +-- - otherwise don't update +setRelationConnected :: Int64 -> MemberRelation -> ByteString -> ByteString +setRelationConnected i newStatus v + | newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected = v + | otherwise = case status' of + Nothing -> v + Just s -> setRelation i s v + where + oldStatus = getRelation i v + status' = case (oldStatus, newStatus) of + -- complementary statuses -> MRConnected + (MRSubjectConnected, MRReferencedConnected) -> Just MRConnected + (MRReferencedConnected, MRSubjectConnected) -> Just MRConnected + -- newStatus > oldStatus -> set newStatus + _ | newStatus > oldStatus -> Just newStatus + | otherwise -> Nothing + +-- | Set a new relation with both direction and status at a given index. +-- Expands the vector lazily if needed. +setNewRelation :: Int64 -> IntroductionDirection -> MemberRelation -> ByteString -> ByteString +setNewRelation i dir r v + | i >= 0 = setNewRelations [(i, (dir, r))] v + | otherwise = v + +-- | Set multiple new relations with both direction and status at once. +-- Expands the vector lazily if needed. +setNewRelations :: [(Int64, (IntroductionDirection, MemberRelation))] -> ByteString -> ByteString +setNewRelations = setRelations_ $ \(dir, r) b -> (b .&. relationMask) .|. (toIntroDirInt dir `shiftL` 3) .|. toRelationInt r + where + relationMask = complement (statusMask .|. directionMask) + +setRelations_ :: (r -> Word8 -> Word8) -> [(Int64, r)] -> ByteString -> ByteString +setRelations_ _ [] v = v +setRelations_ updateByte relations v = let (fp, off, len) = toForeignPtr v newLen = max len $ fromIntegral $ maximum (map fst relations) + 1 in unsafeCreate newLen $ \ptr -> do withForeignPtr fp $ \vPtr -> copyBytes ptr (vPtr `plusPtr` off) len when (newLen > len) $ fillBytes (ptr `plusPtr` len) 0 (newLen - len) - forM_ relations $ \(ix, r) -> when (ix >= 0) $ do + forM_ relations $ \(ix, r) -> when (ix >= 0) $ let i = fromIntegral ix - b <- peekByteOff ptr i - let b' = (b .&. complement relationMask) .|. toRelationInt r - pokeByteOff ptr i b' + in pokeByteOff ptr i . updateByte r =<< peekByteOff ptr i -relationMask :: Word8 -relationMask = 0x07 -- reserving 3 bits +statusMask :: Word8 +statusMask = 0x07 -- bits 0-2 + +directionMask :: Word8 +directionMask = 0x08 -- bit 3 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c16f76148f..f4a995adfe 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -24,7 +24,6 @@ import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, sortOn) import Data.List.NonEmpty (NonEmpty (..)) -import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4c5efa26b6..42562d8e16 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,6 +18,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) +import Data.Maybe (fromMaybe) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.List (intercalate, isInfixOf) @@ -30,10 +31,12 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport @@ -1769,6 +1772,7 @@ testGroupDelayedModeration ps = do -- and forwarding client doesn't check compatibility) void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + updateGroupForwardingVectors alice "bob" "cath" MRConnected cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" @@ -1815,6 +1819,7 @@ testGroupDelayedModerationFullDelete ps = do -- and forwarding client doesn't check compatibility) void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + updateGroupForwardingVectors alice "bob" "cath" MRConnected cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" @@ -5018,7 +5023,7 @@ testGroupMsgForwardReport = setupGroupForwarding :: TestCC -> TestCC -> TestCC -> IO () setupGroupForwarding host invitee1 invitee2 = do - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + threadDelay 1000000 -- delay so member relations don't get overwritten to connected invitee1Name <- userName invitee1 invitee2Name <- userName invitee2 @@ -5050,15 +5055,60 @@ setupGroupForwarding host invitee1 invitee2 = do |] (invitee1Name, invitee2Name) + setupGroupForwardingVectors host invitee1 invitee2 + +setupGroupForwardingVectors :: TestCC -> TestCC -> TestCC -> IO () +setupGroupForwardingVectors host invitee1 invitee2 = do + invitee1Name <- userName invitee1 + invitee2Name <- userName invitee2 + updateGroupForwardingVectors host invitee1Name invitee2Name MRIntroduced + +updateGroupForwardingVectors :: TestCC -> String -> String -> MemberRelation -> IO () +updateGroupForwardingVectors host invitee1Name invitee2Name relation = do + void $ withCCTransaction host $ \db -> do + [(invitee1Index, invitee1Vec)] <- DB.query db + [sql| + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + |] + (Only invitee1Name) + [(invitee2Index, invitee2Vec)] <- DB.query db + [sql| + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + |] + (Only invitee2Name) + + let invitee1Vec' = setRelation invitee2Index relation (fromMaybe B.empty invitee1Vec) + DB.execute db + [sql| + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + |] + (Binary invitee1Vec', invitee1Name) + + let invitee2Vec' = setRelation invitee1Index relation (fromMaybe B.empty invitee2Vec) + DB.execute db + [sql| + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + |] + (Binary invitee2Vec', invitee2Name) + testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeduplicate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + threadDelay 1000000 -- delay so member relations don't get overwritten to connected void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + setupGroupForwardingVectors alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" diff --git a/tests/MemberRelationsTests.hs b/tests/MemberRelationsTests.hs index 968dcbec43..c1afbd7f6a 100644 --- a/tests/MemberRelationsTests.hs +++ b/tests/MemberRelationsTests.hs @@ -29,21 +29,23 @@ memberRelationsTests = do getRelation 0 vec `shouldBe` MRIntroduced it "reads multiple relations" $ do - let vec = B.pack [0, 0, 1, 2] + let vec = B.pack [0, 0, 1, 2, 3, 4] getRelation 0 vec `shouldBe` MRNew getRelation 1 vec `shouldBe` MRNew getRelation 2 vec `shouldBe` MRIntroduced - getRelation 3 vec `shouldBe` MRConnected + getRelation 3 vec `shouldBe` MRSubjectConnected + getRelation 4 vec `shouldBe` MRReferencedConnected + getRelation 5 vec `shouldBe` MRConnected it "reads multiple relations 2" $ do let vec = B.pack [1, 1, 0, 0, 2, 2, 0, 0] getRelation 0 vec `shouldBe` MRIntroduced getRelation 1 vec `shouldBe` MRIntroduced - getRelation 4 vec `shouldBe` MRConnected - getRelation 5 vec `shouldBe` MRConnected + getRelation 4 vec `shouldBe` MRSubjectConnected + getRelation 5 vec `shouldBe` MRSubjectConnected it "ignore reserved bits" $ do - let vec = B.pack [0xF9] -- 11111001 + let vec = B.pack [0xF1] -- reserved=1111, direction=0, status=001 getRelation 0 vec `shouldBe` MRIntroduced describe "setRelation" $ do @@ -56,9 +58,9 @@ memberRelationsTests = do vec `shouldBe` B.empty it "expands vector to required length" $ do - let vec = setRelation 5 MRConnected B.empty + let vec = setRelation 5 MRSubjectConnected B.empty B.length vec `shouldBe` 6 - getRelation 5 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRSubjectConnected -- Other positions should be MRNew (0) getRelation 0 vec `shouldBe` MRNew getRelation 10 vec `shouldBe` MRNew @@ -69,21 +71,21 @@ memberRelationsTests = do let vec1 = setRelation 0 MRIntroduced B.empty let vec2 = setRelation 1 MRIntroduced vec1 -- Update: [01][10][00][00] - let vec3 = setRelation 1 MRConnected vec2 + let vec3 = setRelation 1 MRSubjectConnected vec2 getRelation 0 vec3 `shouldBe` MRIntroduced - getRelation 1 vec3 `shouldBe` MRConnected + getRelation 1 vec3 `shouldBe` MRSubjectConnected it "updates relation in specific byte of multi-byte vector" $ do let vec1 = setRelation 0 MRIntroduced B.empty - let vec2 = setRelation 10 MRConnected vec1 + let vec2 = setRelation 10 MRSubjectConnected vec1 B.length vec2 `shouldBe` 11 getRelation 0 vec2 `shouldBe` MRIntroduced - getRelation 10 vec2 `shouldBe` MRConnected + getRelation 10 vec2 `shouldBe` MRSubjectConnected forM_ [1..9] $ \i -> getRelation i vec2 `shouldBe` MRNew it "handles setting relation at last position in byte" $ do - let vec = setRelation 3 MRConnected B.empty - getRelation 3 vec `shouldBe` MRConnected + let vec = setRelation 3 MRSubjectConnected B.empty + getRelation 3 vec `shouldBe` MRSubjectConnected it "preserves vector when setting same value" $ do let vec1 = setRelation 0 MRIntroduced B.empty @@ -91,12 +93,24 @@ memberRelationsTests = do vec2 `shouldBe` vec1 getRelation 0 vec2 `shouldBe` MRIntroduced - it "preserves reserved bits" $ do - let v = B.pack [0xF8] -- 11111000 + it "preserves reserved bits and direction" $ do + let v = B.pack [0xF8] -- reserved=1111, direction=1, status=000 getRelation 0 v `shouldBe` MRNew - let v' = setRelation 0 MRIntroduced v - getRelation 0 v' `shouldBe` MRIntroduced - B.unpack v' `shouldBe` [0xF9] -- 11111001 + let v' = setRelation 0 MRConnected v + getRelation 0 v' `shouldBe` MRConnected + B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100 + + describe "setNewRelation" $ do + it "sets new relation with direction" $ do + let vec = setNewRelation 0 IDReferencedIntroduced MRSubjectConnected B.empty + getRelation' 0 vec `shouldBe` (IDReferencedIntroduced, MRSubjectConnected) + B.unpack vec `shouldBe` [0x0A] -- direction=1, status=010 + + it "preserves reserved bits" $ do + let v = B.pack [0xF0] -- reserved=1111, direction=0, status=000 + let v' = setNewRelation 0 IDReferencedIntroduced MRConnected v + getRelation 0 v' `shouldBe` MRConnected + B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100 describe "setRelations" $ do it "returns same vector for empty list" $ do @@ -104,106 +118,183 @@ memberRelationsTests = do setRelations [] vec `shouldBe` vec it "sets multiple relations in empty vector" $ do - let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRIntroduced)] + let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRReferencedConnected), (3, MRConnected)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced - getRelation 1 vec `shouldBe` MRConnected - getRelation 2 vec `shouldBe` MRIntroduced - getRelation 3 vec `shouldBe` MRNew -- Unset position + getRelation 1 vec `shouldBe` MRSubjectConnected + getRelation 2 vec `shouldBe` MRReferencedConnected + getRelation 3 vec `shouldBe` MRConnected + getRelation 4 vec `shouldBe` MRNew -- Unset position it "sets multiple relations 1" $ do - let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRConnected), (3, MRIntroduced)] + let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRSubjectConnected), (3, MRIntroduced)] let vec = setRelations updates B.empty B.length vec `shouldBe` 4 getRelation 0 vec `shouldBe` MRIntroduced - getRelation 1 vec `shouldBe` MRConnected - getRelation 2 vec `shouldBe` MRConnected + getRelation 1 vec `shouldBe` MRSubjectConnected + getRelation 2 vec `shouldBe` MRSubjectConnected getRelation 3 vec `shouldBe` MRIntroduced it "sets multiple relations 2" $ do - let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRIntroduced)] let vec = setRelations updates B.empty B.length vec `shouldBe` 11 getRelation 0 vec `shouldBe` MRIntroduced - getRelation 5 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRSubjectConnected getRelation 10 vec `shouldBe` MRIntroduced getRelation 7 vec `shouldBe` MRNew -- Unset position between it "handles sparse updates (few indices in large range)" $ do -- Sparse: 3 updates in large group - let updates = [(0, MRIntroduced), (100, MRConnected), (5000, MRIntroduced)] + let updates = [(0, MRIntroduced), (100, MRSubjectConnected), (5000, MRIntroduced)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced - getRelation 100 vec `shouldBe` MRConnected + getRelation 100 vec `shouldBe` MRSubjectConnected getRelation 5000 vec `shouldBe` MRIntroduced getRelation 50 vec `shouldBe` MRNew -- Untouched position it "handles dense updates (many consecutive indices)" $ do -- Dense: many consecutive updates - let updates = [(i, if even i then MRIntroduced else MRConnected) | i <- [0 .. 99]] + let updates = [(i, if even i then MRIntroduced else MRSubjectConnected) | i <- [0 .. 99]] let vec = setRelations updates B.empty - all (\i -> getRelation i vec == (if even i then MRIntroduced else MRConnected)) [0 .. 99] `shouldBe` True + all (\i -> getRelation i vec == (if even i then MRIntroduced else MRSubjectConnected)) [0 .. 99] `shouldBe` True it "handles unsorted input correctly" $ do - let updates = [(10, MRConnected), (2, MRIntroduced), (5, MRConnected), (0, MRIntroduced)] + let updates = [(10, MRSubjectConnected), (2, MRIntroduced), (5, MRSubjectConnected), (0, MRIntroduced)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced getRelation 2 vec `shouldBe` MRIntroduced - getRelation 5 vec `shouldBe` MRConnected - getRelation 10 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRSubjectConnected + getRelation 10 vec `shouldBe` MRSubjectConnected it "handles duplicate indices (last one wins)" $ do - let updates = [(0, MRIntroduced), (0, MRConnected), (0, MRIntroduced)] + let updates = [(0, MRIntroduced), (0, MRSubjectConnected), (0, MRIntroduced)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced it "preserves existing relations not in update list" $ do - let vec1 = setRelation 0 MRConnected B.empty + let vec1 = setRelation 0 MRSubjectConnected B.empty let vec2 = setRelation 5 MRIntroduced vec1 - let updates = [(10, MRConnected)] + let updates = [(10, MRSubjectConnected)] let vec3 = setRelations updates vec2 - getRelation 0 vec3 `shouldBe` MRConnected + getRelation 0 vec3 `shouldBe` MRSubjectConnected getRelation 5 vec3 `shouldBe` MRIntroduced - getRelation 10 vec3 `shouldBe` MRConnected + getRelation 10 vec3 `shouldBe` MRSubjectConnected + + describe "setNewRelations" $ do + it "sets multiple new relations with direction" $ do + let updates = [(0, (IDSubjectIntroduced, MRIntroduced)), (1, (IDReferencedIntroduced, MRSubjectConnected))] + let vec = setNewRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRSubjectConnected + B.unpack vec `shouldBe` [0x01, 0x0A] -- [dir=0,status=001], [dir=1,status=010] describe "edge cases and invariants" $ do it "round-trip: set then get returns same value" $ do - let vec1 = setRelation 42 MRConnected B.empty - getRelation 42 vec1 `shouldBe` MRConnected + let vec1 = setRelation 42 MRSubjectConnected B.empty + getRelation 42 vec1 `shouldBe` MRSubjectConnected it "multiple round-trips preserve values" $ do let vec1 = setRelation 0 MRIntroduced B.empty - let vec2 = setRelation 1 MRConnected vec1 - let vec3 = setRelation 2 MRIntroduced vec2 - getRelation 0 vec3 `shouldBe` MRIntroduced - getRelation 1 vec3 `shouldBe` MRConnected - getRelation 2 vec3 `shouldBe` MRIntroduced + let vec2 = setRelation 1 MRSubjectConnected vec1 + let vec3 = setRelation 2 MRReferencedConnected vec2 + let vec4 = setRelation 3 MRConnected vec3 + getRelation 0 vec4 `shouldBe` MRIntroduced + getRelation 1 vec4 `shouldBe` MRSubjectConnected + getRelation 2 vec4 `shouldBe` MRReferencedConnected + getRelation 3 vec4 `shouldBe` MRConnected it "setRelations equivalent to multiple setRelation calls" $ do - let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRConnected)] let vecBatch = setRelations updates B.empty - let vecSeq = setRelation 10 MRIntroduced $ setRelation 5 MRConnected $ setRelation 0 MRIntroduced B.empty + let vecSeq = setRelation 10 MRConnected $ setRelation 5 MRSubjectConnected $ setRelation 0 MRIntroduced B.empty vecBatch `shouldBe` vecSeq getRelation 0 vecBatch `shouldBe` getRelation 0 vecSeq getRelation 5 vecBatch `shouldBe` getRelation 5 vecSeq getRelation 10 vecBatch `shouldBe` getRelation 10 vecSeq it "handles large group size (10000 members)" $ do - let updates = [(0, MRIntroduced), (5000, MRConnected), (9999, MRIntroduced)] + let updates = [(0, MRIntroduced), (5000, MRSubjectConnected), (9999, MRIntroduced)] let vec = setRelations updates B.empty B.length vec `shouldBe` 10000 getRelation 0 vec `shouldBe` MRIntroduced - getRelation 5000 vec `shouldBe` MRConnected + getRelation 5000 vec `shouldBe` MRSubjectConnected getRelation 9999 vec `shouldBe` MRIntroduced it "all status values can be stored and retrieved" $ do let vec1 = setRelation 0 MRNew B.empty let vec2 = setRelation 1 MRIntroduced vec1 - let vec3 = setRelation 2 MRConnected vec2 - getRelation 0 vec3 `shouldBe` MRNew - getRelation 1 vec3 `shouldBe` MRIntroduced - getRelation 2 vec3 `shouldBe` MRConnected + let vec3 = setRelation 2 MRSubjectConnected vec2 + let vec4 = setRelation 3 MRReferencedConnected vec3 + let vec5 = setRelation 4 MRConnected vec4 + getRelation 0 vec5 `shouldBe` MRNew + getRelation 1 vec5 `shouldBe` MRIntroduced + getRelation 2 vec5 `shouldBe` MRSubjectConnected + getRelation 3 vec5 `shouldBe` MRReferencedConnected + getRelation 4 vec5 `shouldBe` MRConnected it "vector length is minimal (lazy expansion)" $ do - let vec = setRelation 3 MRConnected B.empty + let vec = setRelation 3 MRSubjectConnected B.empty B.length vec `shouldBe` 4 + + it "setRelation preserves existing direction" $ do + let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty + let vec2 = setRelation 0 MRConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + B.unpack vec2 `shouldBe` [0x0C] -- direction=1 preserved, status=100 + + describe "setRelationConnected" $ do + it "MRSubjectConnected on MRIntroduced -> MRSubjectConnected" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation 0 vec2 `shouldBe` MRSubjectConnected + + it "MRReferencedConnected on MRIntroduced -> MRReferencedConnected" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + getRelation 0 vec2 `shouldBe` MRReferencedConnected + + it "MRSubjectConnected on MRReferencedConnected -> MRConnected (complementary)" $ do + let vec1 = setRelation 0 MRReferencedConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + + it "MRReferencedConnected on MRSubjectConnected -> MRConnected (complementary)" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + + it "MRSubjectConnected on MRSubjectConnected -> no change" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + vec2 `shouldBe` vec1 + + it "MRReferencedConnected on MRReferencedConnected -> no change" $ do + let vec1 = setRelation 0 MRReferencedConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + vec2 `shouldBe` vec1 + + it "MRSubjectConnected on MRConnected -> no change" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + vec2 `shouldBe` vec1 + + it "MRReferencedConnected on MRConnected -> no change" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + vec2 `shouldBe` vec1 + + it "invalid status (MRConnected) -> no change" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRConnected vec1 + vec2 `shouldBe` vec1 + + it "invalid status (MRNew) -> no change" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRNew vec1 + vec2 `shouldBe` vec1 + + it "setRelationConnected preserves direction when updating" $ do + let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation' 0 vec2 `shouldBe` (IDReferencedIntroduced, MRSubjectConnected) diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index b1b7dbc264..8a716ddf11 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -18,6 +18,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T import Database.SQLite.Simple (Query (..)) +import Simplex.Chat.Options.SQLite (chatDBFunctions) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) @@ -63,7 +64,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) + void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -71,14 +72,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) + void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) + Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -152,7 +153,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appChatQueryPlans chatQueryStats - (createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing)) + (createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing)) (\db -> do DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" @@ -161,7 +162,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appAgentQueryPlans agentQueryStats - (createAgentStore (DBOpts testAgentDB "" False True TQOff) (MigrationConfig MCError Nothing)) + (createAgentStore (DBOpts testAgentDB [] "" False True TQOff) (MigrationConfig MCError Nothing)) (const $ pure ()) chatSavedPlans' == chatSavedPlans `shouldBe` True agentSavedPlans' == agentSavedPlans `shouldBe` True