Skip to content

Commit a000e81

Browse files
committed
fix(message_queue): prevent a 502 from stopping the bot
The bot stopped for more than 5 hours. The process continued to run, and systemd showed NRestarts=0, but the bot wrote no more lines to the journal. The progress message of a job stayed at "Step 19/25", but the job was complete and the push to GitLab was successful. Cause: the read timeout and the write timeout apply to one socket operation only. They do not apply to the full request. The Telegram API server sent a 502 page through a slow proxy. Each part of that page started the socket timeout again, thus the request continued for an unlimited time. The consumer sends one message at a time. Thus this one request stopped all messages. The "except Exception" block in the consumer loop did not operate, because the code raised no error. This commit makes three changes: 1. Time limit. Each request to Telegram now has a limit on the total time. If the limit passes, the code raises NetworkError. The usual retry code then does the retry. The limit applies to the queue, to the immediate messages, to the automatic deletions, and to the check that the worker does before a dump. Without a limit, that check can keep a worker for as much as job_timeout, which is 2 hours. 2. Shorter errors. The function sanitize_telegram_error() keeps the status code and a short part of the body. The failure wrote more than 700 lines of HTML to the journal. It now writes one line. The bot also has an error handler for the poll loop, thus that loop writes short lines too. 3. Correct order of edits. Each edit of a Telegram message gets an order number from Redis. Redis keeps the number of the last edit that Telegram accepted. The bot discards an edit whose number is less than that number. Example: step 20 of 25 fails, then step 21 of 25 succeeds. The bot then discards step 20 and does not show an earlier step. A retry of an edit that has a number keeps that number, thus an old edit cannot move in front of a later edit. Two conditions need more care: - If Redis loses the counter key but keeps the watermark key, the counter must not start again at 1. All new numbers would then be less than the watermark, and the bot would discard all subsequent edits. Thus the script starts the counter at the watermark. - A message that a version before this one put in the queue has no order number. If Telegram accepted an edit for the same message, that edit is more recent. Thus the bot discards the message with no number. If Telegram accepted no edit, the bot sends the message. The Lua scripts are in the new file dumpyarabot/lua_scripts.py. Each script does a compare and a write. Redis runs a script from the start to the end and does no other command at the same time. Thus two workers cannot change a key between the compare and the write. The full cycle of test, edit, and mark is not one operation, but only the bot process reads the queue.
1 parent b064390 commit a000e81

6 files changed

Lines changed: 823 additions & 75 deletions

File tree

dumpyarabot/__main__.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import os
22
import sys
3+
import logging
34

5+
from telegram.error import TelegramError
46
from telegram.ext import (ApplicationBuilder, CallbackQueryHandler,
5-
CommandHandler, MessageHandler, filters, JobQueue)
7+
CommandHandler, ContextTypes, MessageHandler,
8+
filters, JobQueue)
69

710
from dumpyarabot.handlers import cancel_dump, clear_queue, dump, help_command, restart, status
8-
from dumpyarabot.message_queue import message_queue
11+
from dumpyarabot.message_queue import message_queue, sanitize_telegram_error
912
from dumpyarabot.mockup_handlers import (handle_enhanced_callback_query,
1013
mockup_command)
1114
from dumpyarabot.moderated_handlers import (accept_command,
@@ -105,6 +108,39 @@ async def _shutdown_runtime(application):
105108
pass
106109

107110

111+
async def _handle_application_error(
112+
update: object, context: ContextTypes.DEFAULT_TYPE
113+
) -> None:
114+
"""Write a short line for a failure in the poll loop or in a handler.
115+
116+
Without this handler, the library writes the full body of the error. A
117+
proxy can send an HTML error page that is more than 700 lines long.
118+
119+
Args:
120+
update: The update that caused the failure. The update can be None if
121+
the failure occurred in the poll loop.
122+
context: The context that holds the error in its error field.
123+
"""
124+
from rich.console import Console
125+
126+
if isinstance(context.error, TelegramError):
127+
Console().print(
128+
f"Telegram application error: {sanitize_telegram_error(context.error)}",
129+
style="red",
130+
markup=False,
131+
)
132+
return
133+
134+
logging.getLogger(__name__).error(
135+
"Unhandled application error",
136+
exc_info=(
137+
type(context.error),
138+
context.error,
139+
context.error.__traceback__,
140+
),
141+
)
142+
143+
108144
async def register_bot_commands(application):
109145
"""Register bot commands with Telegram for the menu interface."""
110146
from dumpyarabot.config import USER_COMMANDS
@@ -163,6 +199,7 @@ async def register_bot_commands_job(context):
163199
application.add_handler(callback_handler)
164200
application.add_handler(restart_handler)
165201
application.add_handler(clearqueue_handler)
202+
application.add_error_handler(_handle_application_error)
166203

167204
application.post_init = _startup_init
168205
application.post_shutdown = _shutdown_runtime

dumpyarabot/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ class Settings(BaseSettings):
2828
DEFAULT_PARSE_MODE: str = "Markdown"
2929
TELEGRAM_TEXT_READ_TIMEOUT: float = 60.0
3030
TELEGRAM_TEXT_WRITE_TIMEOUT: float = 60.0
31+
TELEGRAM_TEXT_REQUEST_TIMEOUT: float = 135.0
3132
TELEGRAM_DOCUMENT_READ_TIMEOUT: float = 120.0
3233
TELEGRAM_DOCUMENT_WRITE_TIMEOUT: float = 120.0
34+
TELEGRAM_DOCUMENT_REQUEST_TIMEOUT: float = 255.0
3335

3436
# Optional custom base URL for Telegram Bot API (e.g. nginx reverse proxy)
3537
# Default: https://api.telegram.org/bot

dumpyarabot/lua_scripts.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Lua scripts that keep the order of Telegram message edits.
2+
3+
The bot shows the progress of a job in one Telegram message. The bot edits
4+
that message for each step. Two workers and the bot can send edits at the
5+
same time, thus the edits can arrive in the wrong order.
6+
7+
Two Redis keys control the order. Each key applies to one Telegram message,
8+
and the key name contains the chat and the message:
9+
10+
- The counter key gives an order number to each edit.
11+
- The watermark key holds the order number of the last edit that Telegram
12+
accepted.
13+
14+
An edit whose order number is less than the watermark is too old. The bot
15+
discards such an edit. Example: step 20 of 25 fails and waits for a retry.
16+
Step 21 of 25 then succeeds and moves the watermark. Step 20 is now too old.
17+
If the bot sent step 20, the display would show an earlier step.
18+
19+
Each script does a compare and a write. Redis runs a script from the start to
20+
the end and does no other command at the same time. Thus no worker can change
21+
a key between the compare and the write. Python code that did a GET and then
22+
a SET would not be safe.
23+
24+
Note: one script is safe, but the full cycle is not one operation. The bot
25+
does the stale test, then sends the edit to Telegram, then moves the
26+
watermark. Only the bot process reads the queue. The arq workers only write
27+
to the queue. If two bot processes read the queue at the same time, the two
28+
processes can send edits in the wrong order.
29+
"""
30+
31+
# Give the next edit of one Telegram message its order number.
32+
#
33+
# If Redis loses the counter key but keeps the watermark key, the counter
34+
# starts again at 1. All new edits would then be less than the watermark, and
35+
# the bot would discard all of them. The message would stay at the same step
36+
# for all time. To prevent this, start the counter at the watermark.
37+
#
38+
# KEYS[1]: the counter key.
39+
# KEYS[2]: the watermark key.
40+
# ARGV[1]: the time-to-live of the key, in seconds.
41+
#
42+
# Returns the new order number.
43+
STAMP_EDIT_SEQUENCE: str = """
44+
if redis.call('EXISTS', KEYS[1]) == 0 then
45+
local applied = tonumber(redis.call('GET', KEYS[2]))
46+
if applied then
47+
redis.call('SET', KEYS[1], applied)
48+
end
49+
end
50+
local sequence = redis.call('INCR', KEYS[1])
51+
redis.call('EXPIRE', KEYS[1], ARGV[1])
52+
return sequence
53+
"""
54+
55+
# Tell if a later edit replaced this edit.
56+
#
57+
# KEYS[1]: the watermark key.
58+
# ARGV[1]: the order number of the edit.
59+
#
60+
# Returns 1 if the edit is too old, or 0 if the edit is current.
61+
IS_STALE_EDIT: str = """
62+
local applied = tonumber(redis.call('GET', KEYS[1]))
63+
local incoming = tonumber(ARGV[1])
64+
if applied and incoming < applied then
65+
return 1
66+
end
67+
return 0
68+
"""
69+
70+
# Move the watermark forward after Telegram accepts an edit.
71+
#
72+
# The script writes only if the order number is more than the watermark. Thus
73+
# a slow edit that arrives late cannot move the watermark to the rear.
74+
#
75+
# KEYS[1]: the watermark key.
76+
# ARGV[1]: the order number of the edit.
77+
# ARGV[2]: the time-to-live of the key, in seconds.
78+
#
79+
# Returns 1 if the script moved the watermark, or 0 if it did not.
80+
MARK_EDIT_APPLIED: str = """
81+
local applied = tonumber(redis.call('GET', KEYS[1]))
82+
local incoming = tonumber(ARGV[1])
83+
local ttl = tonumber(ARGV[2])
84+
if not applied or incoming > applied then
85+
redis.call('SET', KEYS[1], incoming, 'EX', ttl)
86+
return 1
87+
end
88+
if incoming == applied then
89+
redis.call('EXPIRE', KEYS[1], ttl)
90+
end
91+
return 0
92+
"""
93+
94+
# Put an edit in the queue again, but only if a later edit did not replace it.
95+
#
96+
# The compare and the write occur in one script. Two Redis commands would let
97+
# another worker move the watermark between the two commands. The bot would
98+
# then put an edit in the queue that it must discard.
99+
#
100+
# KEYS[1]: the watermark key.
101+
# KEYS[2]: the destination queue.
102+
# ARGV[1]: the order number of the edit.
103+
# ARGV[2]: 'zset' for a delayed retry, or 'list' for an immediate retry.
104+
# ARGV[3]: the score for the delayed set, as a UNIX time.
105+
# ARGV[4]: the message, as JSON.
106+
#
107+
# Returns 1 if the script put the message in the queue, or 0 if the message
108+
# is too old.
109+
REQUEUE_EDIT_IF_CURRENT: str = """
110+
local applied = tonumber(redis.call('GET', KEYS[1]))
111+
local incoming = tonumber(ARGV[1])
112+
if applied and incoming < applied then
113+
return 0
114+
end
115+
if ARGV[2] == 'zset' then
116+
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[4])
117+
else
118+
redis.call('LPUSH', KEYS[2], ARGV[4])
119+
end
120+
return 1
121+
"""
122+
123+
# Keep the status text of a job, but only if the text is not older than the
124+
# text in Redis. The value has this format: "v1\n<sequence>\n<time>\n<text>".
125+
#
126+
# KEYS[1]: the status-text key.
127+
# ARGV[1]: the order number of the text.
128+
# ARGV[2]: the time of the text, as a UNIX time.
129+
# ARGV[3]: the time-to-live of the key, in seconds.
130+
# ARGV[4]: the full value to write.
131+
#
132+
# Returns 1 if the script wrote the text, or 0 if the text is too old.
133+
STORE_LATEST_STATUS_TEXT: str = """
134+
local current = redis.call('GET', KEYS[1])
135+
local next_seq = tonumber(ARGV[1])
136+
local next_ts = tonumber(ARGV[2])
137+
local ttl = tonumber(ARGV[3])
138+
139+
local current_seq = nil
140+
local current_ts = nil
141+
if current and string.sub(current, 1, 3) == "v1\\n" then
142+
local rest = string.sub(current, 4)
143+
local first_newline = string.find(rest, "\\n", 1, true)
144+
if first_newline then
145+
current_seq = tonumber(string.sub(rest, 1, first_newline - 1))
146+
local rest_after_seq = string.sub(rest, first_newline + 1)
147+
local second_newline = string.find(rest_after_seq, "\\n", 1, true)
148+
if second_newline then
149+
current_ts = tonumber(string.sub(rest_after_seq, 1, second_newline - 1))
150+
end
151+
end
152+
end
153+
154+
if not current_seq or next_seq > current_seq or (next_seq == current_seq and (not current_ts or next_ts >= current_ts)) then
155+
redis.call('SET', KEYS[1], ARGV[4], 'EX', ttl)
156+
return 1
157+
end
158+
return 0
159+
"""

0 commit comments

Comments
 (0)