From 54be9456a9a58c3ab3b619ffe7cc3f4e7fd6fc76 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Jul 2026 19:32:09 +0200 Subject: [PATCH 1/9] WPB-26823: make meetings cleanup fully index-driven Add two partial indexes and rewrite 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 (recurring branches of list/cleanup queries). - idx_meetings_end_time_nonrecurring on end_time for non-recurring meetings, so cleanup skips not-yet-expired recurring rows whose original slot is long past. Rewrite getOldMeetingsImpl to issue one bounded, index-backed query per meeting kind (non-recurring vs bounded recurring) and merge the batches in Haskell, mirroring the in-memory store. (hasql-th rejects UNION ALL in a FROM derived table, so the two statements are issued separately and merged.) Both indexes are built CONCURRENTLY and registered in nonTransactionMigrations. EXPLAIN ANALYZE on an adversarial data set (75k not-yet-expired recurring rows with the oldest end_time, few expired rows) drops from 1661 buffers / 11.6ms / 75086 rows filtered to 9 buffers / 0.2ms / 0 filtered. Follow-up to #5309 (discussion r3518905163). --- changelog.d/5-internal/WPB-26823 | 10 +++++ ...0000-meetings-recurrence-eff-end-index.sql | 27 ++++++++++++ ...0-meetings-end-time-nonrecurring-index.sql | 24 ++++++++++ .../src/Wire/MeetingsStore/Postgres.hs | 44 ++++++++++++++----- .../src/Wire/PostgresMigrations.hs | 7 ++- postgres-schema.sql | 14 ++++++ 6 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 changelog.d/5-internal/WPB-26823 create mode 100644 libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql create mode 100644 libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql 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/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/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index 30bfd2fb29..fef638bb6a 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,16 @@ 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 + nonRecurring <- statement (cutoffTime, n) nonRecurringOldStatement + recurring <- statement (cutoffTime, n) recurringOldStatement + pure $ + take batchSize $ + List.sortOn (.endTime) (V.toList nonRecurring <> V.toList recurring) + nonRecurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + nonRecurringOldStatement = refineResult (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) $ [vectorStatement| @@ -429,10 +436,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/postgres-schema.sql b/postgres-schema.sql index c60938bceb..e812ebe37f 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -784,6 +784,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 -- From 8503715c429478c2851c025cd3c3c12c707962c2 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Jul 2026 22:57:25 +0200 Subject: [PATCH 2/9] Hello CI From b99485da525ed208b22fc548e999469287eb571c Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 00:12:33 +0200 Subject: [PATCH 3/9] Hello CI From 374e31a1bc5d5ee2d77c5cb34624c051e2b9a2e1 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 08:46:39 +0200 Subject: [PATCH 4/9] Hello CI From 787219b3ba177b308b1f2897600e0b2f5fcee558 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 13:07:16 +0200 Subject: [PATCH 5/9] Hello CI From 6dd6a707c80a9f552e1f40c2e614573d60f261f8 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 14:21:48 +0200 Subject: [PATCH 6/9] Hello CI From b39dce5d2af25ad1e9357298ab7fd4da5574380b Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 15:29:20 +0200 Subject: [PATCH 7/9] Hello CI From 4e10ff82439862eaf80ef37e1a311b3e16313c29 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 13 Jul 2026 20:02:00 +0200 Subject: [PATCH 8/9] fix(sven): use the effective time instead of end time --- .../src/Wire/MeetingsStore/Postgres.hs | 8 ++++- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 30 +++++++++++++++++++ .../Wire/MockInterpreters/MeetingsStore.hs | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index fef638bb6a..3f1b3b501d 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs @@ -419,11 +419,17 @@ getOldMeetingsImpl cutoffTime batchSize = do n = fromIntegral batchSize :: Int32 session :: Session [StoredMeeting] 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 (.endTime) (V.toList nonRecurring <> V.toList recurring) + List.sortOn effectiveEndTime (V.toList nonRecurring <> V.toList recurring) nonRecurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) nonRecurringOldStatement = refineResult 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 From 33f8315fbc720c96cead29b2dc8c14568c5bcb1f Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 13 Jul 2026 23:38:28 +0200 Subject: [PATCH 9/9] WPB-26823: add meetings_recurrence_consistency CHECK constraint Add a DB-level CHECK constraint encoding the actual recurrence invariant: recurrence_frequency is the master switch. NULL means non-recurring (interval and until must be NULL); NOT NULL means recurring, which requires interval NOT NULL. recurrence_until stays optional to preserve open-ended recurring meetings (the never-expiring state the cleanup query deliberately excludes). Defense-in-depth: the application layer already produces only the three allowed states, so the constraint never rejects a legitimate write. It catches partial writes and raw-SQL corruption at the source. --- .../WPB-26823-recurrence-constraint | 4 +++ ...ings-recurrence-consistency-constraint.sql | 32 +++++++++++++++++++ postgres-schema.sql | 1 + 3 files changed, 37 insertions(+) create mode 100644 changelog.d/5-internal/WPB-26823-recurrence-constraint create mode 100644 libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql 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/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/postgres-schema.sql b/postgres-schema.sql index e812ebe37f..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))