Mercurial > prosody-modules
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);
