changeset 6349:2d256ea0c157

mod_mam_smart_retention: New experimental module for smart retention in MAM archives
author Matthew Wild <mwild1@gmail.com>
date Sat, 06 Dec 2025 18:22:04 +0000
parents 9d3e6d2f25c7
children 908d44cfb693
files mod_mam_smart_retention/README.md mod_mam_smart_retention/mod_mam_smart_retention.lua
diffstat 2 files changed, 199 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_mam_smart_retention/README.md	Sat Dec 06 18:22:04 2025 +0000
@@ -0,0 +1,81 @@
+---
+labels:
+- Stage-Alpha
+summary: Smart retention for MAM archives - expire delivered messages faster
+...
+
+Description
+===========
+
+This module removes messages from the archive after they have been delivered
+to one or all of the user's clients.
+
+Background
+==========
+
+The first module for allowing clients to "catch up" with messages that were
+received while they were offline was mod_offline. Due to the design of that
+protocol, offline messages could only be delivered to a single client before
+the server deleted them.
+
+The newer XEP-0313 protocol, implemented by mod_mam, allows servers to cache
+messages for a configurable retention period. This allows clients to
+synchronize with the user's incoming and outgoing messages, providing a more
+flexible "catch up" protocol that is compatible with multiple devices.
+
+The ideal retention period is long enough for all devices to catch up, but not
+too long (increased storage and processing requirements on the server,
+metadata accumulation, etc.).
+
+This module takes a middle approach. If successful delivery of messages is
+known, then they can be removed from the message archive before the global
+retention period.
+
+Caveats
+=======
+
+To work properly, this module expects clients that:
+
+- Always use XEP-0198 Stream Management
+- Always use XEP-0386 Bind 2
+- Always query the archive using XEP-0313 during login, synchronizing all
+  messages in order (oldest to newest).
+
+If clients do not fit these requirements, you may get unusual results (including,
+in the worst case, expiring messages which have not been delivered).
+
+Note also that cleanup of delivered messages is scheduled daily. This means
+that even with the `"one"` strategy and no minimum retention time set,
+messages are not removed immediately upon delivery, but only after the next
+cleanup task runs.
+
+Configuration
+=============
+
+Just load this module on a host that also has mod_mam. For example:
+
+```lua
+modules_enabled = {
+  ...
+  "mam";
+  "mam_smart_retention";
+  ...
+}
+```
+
+You can then set the following options:
+
+`smart_retention_strategy`
+: Can be `"one"` (default) or `"all"`, depending on whether you want to expire
+  messages that have been delivered to at least one client, or wait until all
+  known clients have received them.
+
+`smart_retention_min`
+: An optional minimum amount of time to keep messages for, even if they have
+  already been delivered to clients.
+
+Compatibility
+=============
+
+Requires Prosody trunk, commit 8fb7408ded73 or later. Not compatible with
+Prosody 13.0 or earlier.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_mam_smart_retention/mod_mam_smart_retention.lua	Sat Dec 06 18:22:04 2025 +0000
@@ -0,0 +1,118 @@
+local um = require "prosody.core.usermanager";
+
+local async = require "prosody.util.async";
+local filters = require "prosody.util.filters";
+
+local archive = module:open_store("archive", "archive");
+local archive_fetches = module:open_store("archive_fetches", "keyval+");
+
+local strategy = module:get_option_enum("smart_retention_strategy", "one", "all");
+local min_retention = module:get_option_period("smart_retention_min", false);
+
+local function get_newest_fetched(username, client_id)
+	archive_fetches:get_key(username, client_id or "");
+end
+
+local function set_newest_fetched(username, client_id, msg_id)
+	archive_fetches:set_key(username, client_id or "", {
+		newest = msg_id;
+	});
+end
+
+function handle_query_result(event)
+	if not event.start_time then
+		module:log("debug", "Ignoring empty query results");
+		return; -- empty results
+	end
+
+	local origin = event.origin;
+
+	if not origin.outgoing_stanza_queue then
+		-- No XEP-0198, so can't reliably determine successful fetches
+		module:log("warn", "Ignoring query on non-smacks stream");
+		return;
+	end
+
+	local newest_result = event.end_id;
+	local newest_fetched = get_newest_fetched(origin.username, origin.client_id);
+
+	if newest_result <= newest_fetched then
+		-- The client hasn't actually fetched anything newer than before
+		return;
+	end
+
+	origin.outgoing_stanza_queue:add_checkpoint(function ()
+		origin.log("debug", "Recording successful delivery of messages  up to %s", newest_result);
+		set_newest_fetched(origin.username, origin.client_id, newest_result);
+	end);
+end
+
+function handle_sent(stanza, session)
+	local msg_id = stanza.name == "message" and stanza:get_meta("archive-id");
+	if not msg_id then return stanza; end
+
+	local queue = session.outgoing_stanza_queue;
+	if not queue then return stanza; end
+
+	queue:add_checkpoint(function ()
+		session.log("debug", "Recording successful delivery of message %s", msg_id);
+		set_newest_fetched(session.username, session.client_id, msg_id);
+	end);
+
+	return stanza;
+end
+
+module:hook("resource-bind", function (event)
+	event.session.log("debug", "Installing filter to track message delivery");
+	filters.add_filter(event.session, "stanzas/out", handle_sent);
+end);
+
+module:hook("archive-query", handle_query_result);
+
+module:daily("Clean up delivered messages", function ()
+	local n_users, n_messages = 0, 0;
+	for username in assert(um.users(module.host)) do
+		local clients = archive_fetches:get(username);
+		if clients then
+			local newest, oldest;
+			for client_id, fetch_info in pairs(clients) do --luacheck: ignore 213/client_id
+				local fetched = fetch_info.newest;
+				if not newest or fetched > newest then
+					newest = fetched;
+				end
+				if not oldest or fetched < oldest then
+					oldest = fetched;
+				end
+			end
+
+			local expire_older_than;
+			if strategy == "one" then
+				expire_older_than = newest;
+			elseif strategy == "all" then
+				expire_older_than = oldest;
+			end
+
+			module:log("debug", "Expiring messages prior to %s for %s", expire_older_than, username);
+
+			local _, expire_before = archive:get(username, expire_older_than);
+
+			local ok, err = archive:delete(username, {
+				["end"] = expire_before;
+				["before"] = min_retention ~= math.huge and (now() - min_retention) or nil;
+			});
+			if ok then
+				local n_deleted = tonumber(ok);
+				if n_deleted then
+					n_messages = n_messages + n_deleted;
+				end
+			else
+				module:log("warn", "Expiry failure: deleting messages before %s for %s: %s", expire_older_than, username, err);
+			end
+			n_users = n_users + 1;
+			async.sleep(0.1);
+		end
+	end
+	if n_users > 0 then
+		module:log("info", "Expired %d delivered messages for %d users", n_messages, n_users);
+	end
+end);