comparison mod_s2s_cache_failures/mod_s2s_cache_failures.lua @ 6343:ee2ebdec1fae

mod_s2s_cache_failures: New module to cache failures to remote domains and control retries
author Matthew Wild <mwild1@gmail.com>
date Thu, 16 Oct 2025 19:08:20 +0100
parents
children 7ed4627ba65d
comparison
equal deleted inserted replaced
6342:4c98f4340bf1 6343:ee2ebdec1fae
1 module:set_global();
2
3 local cache = require "prosody.util.cache";
4 local st = require "prosody.util.stanza";
5 local time = require "prosody.util.time";
6
7 local measure_failed_domains = module:metric(
8 "gauge", "failed_domains", "",
9 "Number of cached domain failures"
10 );
11
12 local measure_rejected_stanzas = module:metric(
13 "counter", "failed_stanzas", "",
14 "Number of rejected stanzas to failed domains"
15 );
16
17 local failed_domains_cache_size = module:get_option_number("s2s_failure_cache_size", 128);
18
19 local domains = cache.new(failed_domains_cache_size);
20
21 -- This function returns an appropriate delay until we should retry connecting
22 -- It includes randomness, so that every Prosody server does not attempt
23 -- to reconnect to the target server at the same time (which may overload it
24 -- and cause further downtime). The logic and numbers here results in approximately
25 -- 15 retries per hour, with up to 30min between attempts.
26 local function get_holdoff_time(first_disconnect, last_disconnect)
27 -- Retry for a server that has been down for 2 hours will be the same
28 -- range as a server that has been down for 1 hour
29 local downtime = math.min(3600, last_disconnect - first_disconnect);
30 -- Range from at least 10-20s up to a range proportional to the downtime
31 return math.random(10 + downtime/4, 20 + downtime/2);
32 end
33
34 module:hook("s2sout-established", function (event)
35 -- Successfully connected, so stop tracking this domain
36 local domain = event.session.to_host;
37 domains:set(domain, nil);
38 end);
39
40 module:hook("s2sout-destroyed", function (event)
41 local domain = event.session.to_host;
42 if not event.reason or event.session.type ~= "s2sout_unauthed" then
43 return;
44 end
45 local current_time = time.now();
46 local holdoff_time = get_holdoff_time(current_time, current_time);
47 local state = domains:get(domain) or { current_time, nil, -1, current_time + holdoff_time, event.reason };
48 state[2], state[3] = current_time, state[3] + 1;
49 domains:set(domain, state);
50 module:log("info", "Preventing further connections to %s for %d seconds", holdoff_time);
51 end);
52
53 module:hook("route/remote", function (event)
54 local origin, stanza, domain = event.origin, event.stanza, event.to_host;
55 local domain_info = domains:get(domain);
56 if domain_info then
57 measure_rejected_stanzas:add(1);
58 local holdoff_until = domain_info[4];
59 if time.now() < holdoff_until then
60 return;
61 end
62 local reply = st.error_reply(
63 stanza,
64 "wait",
65 "remote-server-timeout",
66 domain_info[5] and ("Unreachable domain ("..domain_info[5]..")") or "Unreachable domain"
67 );
68 return origin.send(reply);
69 end
70 end);
71
72 module:hook("stats-update", function ()
73 measure_failed_domains:set(domains:count());
74 end);
75
76 module:add_item("shell-command", {
77 section = "s2s";
78 section_desc = "View and manage server-to-server connections";
79 name = "reset_failures";
80 desc = "Clear cache of connection failures";
81 args = {
82 { name = "remote_host", type = "string" };
83 };
84 handler = function (_, remote_host)
85 if remote_host then
86 if domains:get(remote_host) == nil then
87 return false, "No failure cached for "..remote_host;
88 end
89 domains:set(remote_host, nil);
90 return true, "Reset failure cache for "..remote_host;
91 end
92
93 -- No remote host specified, so clear all
94 domains:clear();
95 return true, "Failure cache cleared";
96 end;
97 });
98
99 module:add_item("shell-command", {
100 section = "s2s";
101 section_desc = "View and manage server-to-server connections";
102 name = "show_failures";
103 desc = "Show cache of connection failures";
104 args = {
105 { name = "remote_host", type = "string" };
106 };
107 handler = function (shell, remote_host)
108 local function show_domain_failures(domain, domain_info)
109 domain_info = domain_info or domains:get(domain);
110 if not domain_info then
111 return false;
112 end
113 local first_disconnect, last_disconnect = domain_info[1], domain_info[2];
114 local attempts, holdoff, reason = domain_info[3], domain_info[4], domain_info[5];
115
116 shell.session.print(domain..":");
117 shell.session.print(" First disconnected: "..os.date("%c", first_disconnect));
118 shell.session.print(" Last disconnected: "..os.date("%c", last_disconnect));
119 if reason then
120 shell.session.print(" because: "..reason);
121 end
122 shell.session.print(" Reconnection attempts: "..attempts);
123 shell.session.print(" No more attempts until: "..os.date("%c", last_disconnect + holdoff));
124
125 return true;
126 end
127 if remote_host then
128 if not show_domain_failures(remote_host) then
129 return false, "No failure cached for "..remote_host;
130 end
131 return true, "Showing failure record for "..remote_host;
132 end
133
134 local c = 0;
135 for domain, domain_info in domains:items() do
136 c = c + 1;
137 show_domain_failures(domain, domain_info);
138 end
139
140 return true, ("Showing %d failure records"):format(c);
141 end;
142 });