Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog.d/5-internal/WPB-26823
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions changelog.d/5-internal/WPB-26823-recurrence-constraint
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <[email protected]>
--
-- 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 <https://www.gnu.org/licenses/>.

-- 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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <[email protected]>
--
-- 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 <https://www.gnu.org/licenses/>.

-- 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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <[email protected]>
--
-- 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 <https://www.gnu.org/licenses/>.

-- 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

Check warning on line 21 in libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-server&issues=AZ9da-kbc0nshPwXpR5G&open=AZ9da-kbc0nshPwXpR5G&pullRequest=5328
-- 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)
);
50 changes: 40 additions & 10 deletions libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +428 to +429

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to document why we're using two queries here. (Not that some future incarnation of myself accidentally optimizes this away... 😉 )

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|
Expand All @@ -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)
|]
7 changes: 6 additions & 1 deletion libs/wire-subsystems/src/Wire/PostgresMigrations.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <-
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions postgres-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
--
Expand Down