diff --git a/changelog.d/5-internal/WPB-26823 b/changelog.d/5-internal/WPB-26823 new file mode 100644 index 0000000000..6bfc80876e --- /dev/null +++ b/changelog.d/5-internal/WPB-26823 @@ -0,0 +1,10 @@ +Added partial indexes and rewrote the meetings cleanup query so the background +worker stays fully index-driven as the `meetings` table grows: +- `idx_meetings_recurrence_eff_end` on `GREATEST(end_time, recurrence_until)` + for bounded recurring meetings (covers the recurring branches of the list and + cleanup queries). +- `idx_meetings_end_time_nonrecurring` on `end_time` for non-recurring meetings, + so cleanup can find expired non-recurring meetings without scanning + not-yet-expired recurring rows whose original slot is long past. +`getOldMeetingsImpl` now issues one bounded, index-backed query per meeting kind +and merges the two batches in Haskell. diff --git a/changelog.d/5-internal/WPB-26823-recurrence-constraint b/changelog.d/5-internal/WPB-26823-recurrence-constraint new file mode 100644 index 0000000000..00cdc11796 --- /dev/null +++ b/changelog.d/5-internal/WPB-26823-recurrence-constraint @@ -0,0 +1,4 @@ +Added a `meetings_recurrence_consistency` CHECK constraint so the recurrence +columns can never be left in a partially-set state (frequency is the master +switch; interval is required when set; recurrence_until stays optional for +open-ended recurring meetings). diff --git a/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql b/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql new file mode 100644 index 0000000000..36b8ba604d --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql @@ -0,0 +1,27 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Partial expression index for the recurring-meeting branches of +-- listMeetingsByUserImpl, listMeetingsByConversationImpl and +-- getOldMeetingsImpl, which filter/order on +-- GREATEST(end_time, recurrence_until). Non-recurring meetings keep using +-- idx_meetings_end_time. See WPB-26823. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_meetings_recurrence_eff_end + ON meetings (GREATEST(end_time, recurrence_until)) + WHERE recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL + AND recurrence_until IS NOT NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql b/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql new file mode 100644 index 0000000000..2a8ae9c141 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql @@ -0,0 +1,24 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Partial index for the non-recurring branch of getOldMeetingsImpl, so the +-- cleanup worker can find expired non-recurring meetings ordered by end_time +-- without scanning not-yet-expired recurring meetings (which carry an old +-- end_time but a recurrence window still open in the future). See WPB-26823. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_meetings_end_time_nonrecurring + ON meetings (end_time) + WHERE recurrence_frequency IS NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql b/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql new file mode 100644 index 0000000000..88447a697d --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql @@ -0,0 +1,32 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Migration: add CHECK constraint enforcing meetings recurrence consistency. +-- Description: recurrence_frequency is the master switch. NULL => non-recurring +-- (interval and until must be NULL). NOT NULL => recurring, which requires +-- interval NOT NULL; recurrence_until stays optional (open-ended recurring +-- meetings never expire and are intentionally excluded from cleanup). + +ALTER TABLE meetings + ADD CONSTRAINT meetings_recurrence_consistency CHECK ( + (recurrence_frequency IS NULL + AND recurrence_interval IS NULL + AND recurrence_until IS NULL) + OR + (recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL) + ); diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index 30bfd2fb29..3f1b3b501d 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs @@ -25,6 +25,7 @@ module Wire.MeetingsStore.Postgres where import Data.Id +import Data.List qualified as List import Data.Profunctor (dimap) import Data.Range (Range, fromRange) import Data.Time.Clock @@ -415,10 +416,22 @@ getOldMeetingsImpl cutoffTime batchSize = do result <- liftIO $ use pool session either throw pure result where + n = fromIntegral batchSize :: Int32 session :: Session [StoredMeeting] - session = statement (cutoffTime, fromIntegral batchSize) $ V.toList <$> listStatement - listStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) - listStatement = + session = do + -- Two separate queries so each branch can use its dedicated partial index: + -- * non-recurring -> idx_meetings_end_time_nonrecurring (end_time) + -- * recurring -> idx_meetings_recurrence_eff_end + -- (GREATEST(end_time, recurrence_until)) + -- A single OR query would match neither partial index and force a scan. + -- Results are merged and re-sorted by 'effectiveEndTime' below. See WPB-26823. + nonRecurring <- statement (cutoffTime, n) nonRecurringOldStatement + recurring <- statement (cutoffTime, n) recurringOldStatement + pure $ + take batchSize $ + List.sortOn effectiveEndTime (V.toList nonRecurring <> V.toList recurring) + nonRecurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + nonRecurringOldStatement = refineResult (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) $ [vectorStatement| @@ -429,10 +442,27 @@ getOldMeetingsImpl cutoffTime batchSize = do conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz FROM meetings - WHERE (recurrence_frequency IS NULL AND end_time < ($1 :: timestamptz)) - OR (recurrence_frequency IS NOT NULL AND recurrence_interval IS NOT NULL - AND recurrence_until IS NOT NULL - AND GREATEST(end_time, recurrence_until) < ($1 :: timestamptz)) - ORDER BY end_time ASC - LIMIT ($2 :: int4) - |] + WHERE recurrence_frequency IS NULL + AND end_time < ($1 :: timestamptz) + ORDER BY end_time ASC + LIMIT ($2 :: int4) + |] + recurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + recurringOldStatement = + refineResult + (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) + $ [vectorStatement| + SELECT + id :: uuid, title :: text, creator :: uuid, + start_time :: timestamptz, end_time :: timestamptz, + recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, + conversation_id :: uuid, invited_emails :: text[], trial :: boolean, + created_at :: timestamptz, updated_at :: timestamptz + FROM meetings + WHERE recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL + AND recurrence_until IS NOT NULL + AND GREATEST(end_time, recurrence_until) < ($1 :: timestamptz) + ORDER BY GREATEST(end_time, recurrence_until) ASC + LIMIT ($2 :: int4) + |] diff --git a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs index d8b022b1ff..559f5c1bd5 100644 --- a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs +++ b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs @@ -42,7 +42,12 @@ allMigrations = map (\(name, contentBS) -> MigrationScript name (Text.decodeUtf8 -- | Scripts which cannot be run in a transaction nonTransactionMigrations :: Set ScriptName -nonTransactionMigrations = Set.fromList ["20260428072649-create-conv-parent-index.sql"] +nonTransactionMigrations = + Set.fromList + [ "20260428072649-create-conv-parent-index.sql", + "20260708090000-meetings-recurrence-eff-end-index.sql", + "20260708100000-meetings-end-time-nonrecurring-index.sql" + ] data PostgresMigrationError = PostgresMigrationError MigrationError deriving (Show) diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 6bfd5e5f53..3a1483efd4 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -964,6 +964,16 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do interval = 1, until = Nothing } + meetingAt endOffset r = + API.NewMeeting + { title = fromJust $ checked "Meeting", + startTime = addUTCTime (endOffset - 3600) now, + endTime = addUTCTime endOffset now, + recurrence = r, + invitedEmails = [] + } + recurUntil t = + Just (API.Recurrence {freq = API.Daily, interval = 1, until = Just t}) it "getMeeting returns a recurring meeting whose slot passed but window is open" $ do result <- @@ -1054,6 +1064,26 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do deleted `shouldBe` 0 remainingId `shouldBe` Just meetingId + it "cleanupOldMeetings deletes in effectiveEndTime order, not endTime order" $ do + result <- + runTestStack now gen Map.empty teamConfig $ do + -- endTime now-4000, no recurrence -> effectiveEndTime now-4000 (earliest) + plain <- createMeeting zUser (meetingAt (-4000) Nothing) + -- endTime now-5000, until now-3500 -> effectiveEndTime now-3500 (later) + recur <- createMeeting zUser (meetingAt (-5000) (recurUntil (addUTCTime (-3500) now))) + _deleted <- cleanupOldMeetings now 1 + plainRemains <- isJust <$> getMeeting zUser plain.meeting.id + recurRemains <- isJust <$> getMeeting zUser recur.meeting.id + pure (plainRemains, recurRemains) + case result of + Left err -> fail $ "Error: " <> show err + Right (plainRemains, recurRemains) -> do + -- plain has the earlier effectiveEndTime, so it is deleted first; + -- recur survives. With the old endTime sort, recur (endTime now-5000) + -- would be deleted first instead. + plainRemains `shouldBe` False + recurRemains `shouldBe` True + prop "aliveness follows effectiveEndTime across get/list/cleanup" $ \(recurrence :: Maybe API.Recurrence) (endOffset :: Int) -> let endTime = addUTCTime (fromIntegral endOffset) now diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs index fde676442c..6f2b59d55d 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs @@ -120,6 +120,6 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case GetOldMeetings cutoffTime batchSize -> gets $ take batchSize - . List.sortOn (.endTime) + . List.sortOn effectiveEndTime . filter (\sm -> maybe False (< cutoffTime) (effectiveEndTime sm)) . Map.elems diff --git a/postgres-schema.sql b/postgres-schema.sql index c60938bceb..948f1d6259 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -303,6 +303,7 @@ CREATE TABLE public.meetings ( trial boolean DEFAULT false NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT meetings_recurrence_consistency CHECK ((((recurrence_frequency IS NULL) AND (recurrence_interval IS NULL) AND (recurrence_until IS NULL)) OR ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL)))), CONSTRAINT meetings_title_length CHECK ((length(title) <= 256)), CONSTRAINT meetings_title_not_empty CHECK ((length(TRIM(BOTH FROM title)) > 0)), CONSTRAINT meetings_valid_time_range CHECK ((end_time > start_time)) @@ -784,6 +785,20 @@ CREATE INDEX idx_meetings_creator ON public.meetings USING btree (creator); CREATE INDEX idx_meetings_end_time ON public.meetings USING btree (end_time); +-- +-- Name: idx_meetings_end_time_nonrecurring; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_meetings_end_time_nonrecurring ON public.meetings USING btree (end_time) WHERE (recurrence_frequency IS NULL); + + +-- +-- Name: idx_meetings_recurrence_eff_end; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_meetings_recurrence_eff_end ON public.meetings USING btree (GREATEST(end_time, recurrence_until)) WHERE ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL) AND (recurrence_until IS NOT NULL)); + + -- -- Name: idx_meetings_start_time; Type: INDEX; Schema: public; Owner: wire-server --