Files
zulip/scripts/lib/check_rabbitmq_queue.py
Alex Vandiver c5200e8b05 deliver_scheduled_emails: Use a queue, instead of infinite retries.
`deliver_scheduled_emails` tries to deliver the email synchronously,
and if it fails, it retries after 10 seconds.  Since it does not track
retries, and always tries the earliest-scheduled-but-due message
first, the worker will not make forward progress if there is a
persistent failure with that message, and will retry indefinitely.
This can result in excessive network or email delivery charges from
the remote SMTP server.

Switch to delivering emails via a new queue worker.  The
`deliver_scheduled_emails` job now serves only to pull deferred jobs
out of the table once they are due, insert them into RabbitMQ, and
then delete them.  This limits the potential for head-of-queue
failures to failures inserting into RabbitMQ, which is more reasonable
than failures speaking to a complex external system we do not control.
Retries and any connections to the SMTP server are left to the
RabbitMQ consumer.

We build a new RabbitMQ queue, rather than use the existing
`email_senders` queue, because that queue is expected to be reasonably
low-latency, for things like missed message notifications.  The
`send_future_email` codepath which inserts into ScheduledEmails is
also (ab)used to digest emails, which are extremely bursty in their
frequency -- and a large burst could significantly delay emails behind
it in the queue.

The new queue is explicitly only for messages which were not initiated
by user actions (e.g., invitation reminders, digests, new account
follow-ups) which are thus not latency-sensitive.

Fixes: #32463.
2025-03-04 16:09:25 -08:00

217 lines
7.2 KiB
Python

import json
import os
import re
import subprocess
import sys
import time
from collections import defaultdict
from typing import Any
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.append(ZULIP_PATH)
from scripts.lib.zulip_tools import atomic_nagios_write, get_config, get_config_file
normal_queues = [
"deferred_work",
"deferred_email_senders",
"digest_emails",
"email_mirror",
"email_senders",
"embed_links",
"embedded_bots",
"missedmessage_emails",
"missedmessage_mobile_notifications",
"outgoing_webhooks",
"thumbnail",
"user_activity",
"user_activity_interval",
]
mobile_notification_shards = int(
get_config(get_config_file(), "application_server", "mobile_notification_shards", "1")
)
OK = 0
WARNING = 1
CRITICAL = 2
UNKNOWN = 3
states = {
0: "OK",
1: "WARNING",
2: "CRITICAL",
3: "UNKNOWN",
}
MAX_SECONDS_TO_CLEAR: defaultdict[str, int] = defaultdict(
lambda: 30,
deferred_work=600,
digest_emails=1200,
missedmessage_mobile_notifications=120,
embed_links=60,
deferred_email_senders=240,
)
CRITICAL_SECONDS_TO_CLEAR: defaultdict[str, int] = defaultdict(
lambda: 60,
deferred_work=900,
missedmessage_mobile_notifications=180,
digest_emails=1800,
embed_links=90,
deferred_email_senders=300,
)
def analyze_queue_stats(
queue_name: str, stats: dict[str, Any], queue_count_rabbitmqctl: int
) -> dict[str, Any]:
now = int(time.time())
if stats == {}:
return dict(status=UNKNOWN, name=queue_name, message="invalid or no stats data")
if now - stats["update_time"] > 180 and queue_count_rabbitmqctl > 10:
# Queue isn't updating the stats file and has some events in
# the backlog, it's likely stuck.
#
# TODO: There's an unlikely race condition here - if the queue
# was fully emptied and was idle due to no new events coming
# for over 180 seconds, suddenly gets a burst of events and
# this code runs exactly in the very small time window between
# those events popping up and the queue beginning to process
# the first one (which will refresh the stats file at the very
# start), we'll incorrectly return the CRITICAL status. The
# chance of that happening should be negligible because the queue
# worker should wake up immediately and log statistics before
# starting to process the first event.
return dict(
status=CRITICAL,
name=queue_name,
message="queue appears to be stuck, last update {}, queue size {}".format(
stats["update_time"], queue_count_rabbitmqctl
),
)
current_size = queue_count_rabbitmqctl
average_consume_time = stats["recent_average_consume_time"]
if average_consume_time is None:
# Queue just started; we can't effectively estimate anything.
#
# If the queue is stuck in this state and not processing
# anything, eventually the `update_time` rule above will fire.
return dict(status=OK, name=queue_name, message="")
expected_time_to_clear_backlog = current_size * average_consume_time
if expected_time_to_clear_backlog > MAX_SECONDS_TO_CLEAR[queue_name]:
if expected_time_to_clear_backlog > CRITICAL_SECONDS_TO_CLEAR[queue_name]:
status = CRITICAL
else:
status = WARNING
return dict(
status=status,
name=queue_name,
message=f"clearing the backlog will take too long: {expected_time_to_clear_backlog}s, size: {current_size}",
)
return dict(status=OK, name=queue_name, message="")
WARN_COUNT_THRESHOLD_DEFAULT = 10
CRITICAL_COUNT_THRESHOLD_DEFAULT = 50
def check_other_queues(queue_counts_dict: dict[str, int]) -> list[dict[str, Any]]:
"""Do a simple queue size check for queues whose workers don't publish stats files."""
results = []
for queue, count in queue_counts_dict.items():
if queue in normal_queues:
continue
if count > CRITICAL_COUNT_THRESHOLD_DEFAULT:
results.append(dict(status=CRITICAL, name=queue, message=f"count critical: {count}"))
elif count > WARN_COUNT_THRESHOLD_DEFAULT:
results.append(dict(status=WARNING, name=queue, message=f"count warning: {count}"))
else:
results.append(dict(status=OK, name=queue, message=""))
return results
def check_rabbitmq_queues() -> None:
pattern = re.compile(r"(\w+)\t(\d+)\t(\d+)")
if "USER" in os.environ and os.environ["USER"] not in ["root", "rabbitmq"]:
print("This script must be run as the root or rabbitmq user")
list_queues_output = subprocess.check_output(
["/usr/sbin/rabbitmqctl", "list_queues", "name", "messages", "consumers"],
text=True,
)
queue_counts_rabbitmqctl = {}
queues_with_consumers = []
for line in list_queues_output.split("\n"):
line = line.strip()
m = pattern.match(line)
if m:
queue = m.group(1)
count = int(m.group(2))
consumers = int(m.group(3))
queue_counts_rabbitmqctl[queue] = count
if consumers > 0 and not queue.startswith("notify_tornado"):
queues_with_consumers.append(queue)
queue_stats_dir = subprocess.check_output(
[os.path.join(ZULIP_PATH, "scripts/get-django-setting"), "QUEUE_STATS_DIR"],
text=True,
).strip()
queue_stats: dict[str, dict[str, Any]] = {}
check_queues = normal_queues
if mobile_notification_shards > 1:
# For sharded queue workers, where there's a separate queue
# for each shard, we need to make sure none of those are
# backlogged.
check_queues += [
f"missedmessage_mobile_notifications_shard{d}"
for d in range(1, mobile_notification_shards + 1)
]
queues_to_check = set(check_queues).intersection(set(queues_with_consumers))
for queue in queues_to_check:
fn = queue + ".stats"
file_path = os.path.join(queue_stats_dir, fn)
if not os.path.exists(file_path):
queue_stats[queue] = {}
continue
with open(file_path) as f:
try:
queue_stats[queue] = json.load(f)
except json.decoder.JSONDecodeError:
queue_stats[queue] = {}
results = []
for queue_name, stats in queue_stats.items():
results.append(analyze_queue_stats(queue_name, stats, queue_counts_rabbitmqctl[queue_name]))
results.extend(check_other_queues(queue_counts_rabbitmqctl))
status = max(result["status"] for result in results)
if status > 0:
queue_error_template = "queue {} problem: {}:{}"
error_message = "; ".join(
queue_error_template.format(result["name"], states[result["status"]], result["message"])
for result in results
if result["status"] > 0
)
sys.exit(
atomic_nagios_write(
"check-rabbitmq-results",
"critical" if status == CRITICAL else "warning",
error_message,
)
)
else:
atomic_nagios_write("check-rabbitmq-results", "ok", "queues normal")