diff plugins/mod_offline.lua @ 13995:30d457b2b0c4

mod_offline: Support for expiring and cleaning up old offline messages This is achieved using the new `offline_expires_after` option, which is practically identical to the same option used for mod_mam expiry (the code is also practically identical). With MAM-only clients, it's possible that messages could end up in the offline queue, but never collected (they would sit there waiting for a legacy client to come online). This doesn't happen much so far because most clients are "legacy" by the heuristic mod_mam is using (MAM request before initial presence), but that is changing. This option also allows simpler retention policies for server operators.
author Matthew Wild <mwild1@gmail.com>
date Thu, 04 Dec 2025 14:07:41 +0000
parents 74b9e05af71e
children
line wrap: on
line diff
--- a/plugins/mod_offline.lua	Thu Dec 04 11:39:34 2025 +0000
+++ b/plugins/mod_offline.lua	Thu Dec 04 14:07:41 2025 +0000
@@ -9,11 +9,22 @@
 
 local datetime = require "prosody.util.datetime";
 local jid_split = require "prosody.util.jid".split;
+local datestamp = require "prosody.util.datetime".date;
+
+local cleanup_after = module:get_option_period("offline_expires_after", "never");
 
 local offline_messages = module:open_store("offline", "archive");
 
 module:add_feature("msgoffline");
 
+function schedule_cleanup(_username, _date) -- luacheck: ignore 212
+	-- Called to make a note of which users have messages on which days, which in
+	-- turn is used to optimize the message expiry routine.
+	--
+	-- This noop is conditionally replaced later depending on retention settings
+	-- and storage backend capabilities.
+end
+
 module:hook("message/offline/handle", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local to = stanza.attr.to;
@@ -26,6 +37,7 @@
 
 	local ok = offline_messages:append(node, nil, stanza, os.time(), "");
 	if ok then
+		schedule_cleanup(node);
 		module:log("debug", "Saved to offline storage: %s", stanza:top_tag());
 	end
 	return ok;
@@ -49,3 +61,93 @@
 	end
 	return true;
 end, -1);
+
+if cleanup_after ~= math.huge then
+	local cleanup_storage = module:open_store("offline_cleanup");
+	local cleanup_map = module:open_store("offline_cleanup", "map");
+
+	module:log("debug", "offline_expires_after = %d -- in seconds", cleanup_after);
+
+	if not offline_messages.delete then
+		module:log("error", "offline_expires_after set but mod_%s does not support deleting", offline_messages._provided_by);
+		return false;
+	end
+
+	-- For each day, store a set of users that have new messages. To expire
+	-- messages, we collect the union of sets of users from dates that fall
+	-- outside the cleanup range.
+
+	if not (offline_messages.caps and offline_messages.caps.wildcard_delete) then
+		local last_date = require "prosody.util.cache".new(module:get_option_integer("archive_cleanup_date_cache_size", 1000, 1));
+		function schedule_cleanup(username, date)
+			date = date or datestamp();
+			if last_date:get(username) == date then return end
+			local ok = cleanup_map:set(date, username, true);
+			if ok then
+				last_date:set(username, date);
+			end
+		end
+	end
+
+	local cleanup_time = module:measure("cleanup", "times");
+
+	local async = require "prosody.util.async";
+	module:daily("Remove expired offline messages", function ()
+		local cleanup_done = cleanup_time();
+
+		if offline_messages.caps and offline_messages.caps.wildcard_delete then
+			local ok, err = offline_messages:delete(true, { ["end"] = os.time() - cleanup_after })
+			if ok then
+				local sum = tonumber(ok);
+				if sum then
+					module:log("info", "Deleted %d expired messages", sum);
+				else
+					-- driver did not tell
+					module:log("info", "Deleted all expired messages");
+				end
+			else
+				module:log("error", "Could not delete messages: %s", err);
+			end
+			cleanup_done();
+			return;
+		end
+
+		local users = {};
+		local cut_off = datestamp(os.time() - cleanup_after);
+		for date in cleanup_storage:users() do
+			if date <= cut_off then
+				module:log("debug", "Messages from %q should be expired", date);
+				local messages_this_day = cleanup_storage:get(date);
+				if messages_this_day then
+					for user in pairs(messages_this_day) do
+						users[user] = true;
+					end
+					if date < cut_off then
+						-- Messages from the same day as the cut-off might not have expired yet,
+						-- but all earlier will have, so clear storage for those days.
+						cleanup_storage:set(date, nil);
+					end
+				end
+			end
+		end
+		local sum, num_users = 0, 0;
+		for user in pairs(users) do
+			local ok, err = offline_messages:delete(user, { ["end"] = os.time() - cleanup_after; })
+			if ok then
+				num_users = num_users + 1;
+				sum = sum + (tonumber(ok) or 0);
+			else
+				cleanup_map:set(cut_off, user, true);
+				module:log("error", "Could not delete messages for user '%s': %s", user, err);
+			end
+			local wait, done = async.waiter();
+			module:add_timer(0.01, done);
+			wait();
+		end
+		module:log("info", "Deleted %d expired messages for %d users", sum, num_users);
+		cleanup_done();
+	end);
+
+else
+	module:log("debug", "Offline message expiry disabled");
+end