Files
telegram-exporter/tests/test_sidecar.py
T
tiennm99 c37598e008 fix: lock the session before opening it, and stop a failed export reading as finished
The session lock was taken after the thing it guards. It lived inside
connected_client, so connect() and _login() had already written the
shared SQLite session by the time the lock existed. Two runs on the
default session each passed their own per-root lock, both opened the same
database, and the loser exited 3 *after* causing the corruption its
message described. The lock now wraps the whole client lifetime, and the
committable-path check runs first so a refused run leaves no lock file
next to a session it was never allowed to create.

--dry-run is inside that lock too. It writes nothing to the export tree,
but it opens the same session file, which is the resource the lock is
about - so running it alongside an export now needs its own --session.

Refusing to mark an export complete required that *nothing* had
succeeded: `failed and not (downloaded or skipped)`. A single
already-present file made skipped non-zero and disabled the guard
outright, so any resume across a partly-complete export could fail every
remaining file and still stamp completed_at with a cursor at
end-of-history. A broken export then answered "did my export finish?"
with a confident yes. The comparison is now against downloaded + skipped;
a legitimate tail of present files still outnumbers its own stray
failures and completes normally.

Also:

- Renewing an expired file reference counted as a failed attempt, so
  expiry on the final attempt burned the last slot and the fresh
  reference was never fetched - reported as "exhausted 3 attempts" after
  two. The refreshed latch already bounds that arm.
- Sidecar repair scanned a fixed window back from EOF for the last
  newline. A partial record larger than the window contains none, so the
  file was truncated to end-window: still unreadable, one megabyte
  shorter. The window now grows until a newline is found.
- --reset-state made the zeroed cursor durable before rotating the old
  sidecar, leaving exactly the mixed-generation log that rotating exists
  to prevent. Rotation now precedes State.open, which is sound only on
  this path because there is no compatibility check to fail.
- Two resets inside one second silently clobbered the first archive
  through os.replace, and the rename was the one here not fsynced.
- title.txt was the only untrusted string written raw. The export root is
  safe because the title never becomes a path component, but cat title.txt
  handed ANSI escapes and a right-to-left override to the operator. It is
  stripped of the same Unicode categories filenames are, from one shared
  set so the two rules cannot drift, and written atomically.

Cost, on the most common path of every resume:

- The post directory was fsynced every post, including posts where every
  file was already present and nothing had been renamed - one fsync per
  post to re-record a directory entry an earlier run had already made
  durable. Gated on an actual download.
- An already-present file was stat'd three times: exists(), stat(), and
  again inside _result. One stat now, reused as the recorded size.

Moving fsync off the loop is not available: the AST scan forbids
to_thread and run_in_executor, deliberately, because parallelism here
buys nothing and escalates flood waits.

Each fix has a test that fails without it, confirmed by reverting the
fix and re-running. 204 tests to 214, coverage unchanged at 93%.
2026-08-22 23:20:00 +07:00

154 lines
5.4 KiB
Python

"""messages.jsonl: partial-line repair, rotation, and union semantics.
Not in the plan's original file list, but phase 5's success criteria name both
behaviours ("a truncated final line is repaired at startup", "--reset-state
rotates the sidecar"), and neither belongs in the download-decision table.
"""
from __future__ import annotations
import json
from telegram_exporter.sidecar import SIDECAR_NAME, Sidecar
from telegram_exporter.traversal import Post
from tests.support import FakeMsg
def lines(root):
return (root / SIDECAR_NAME).read_text().splitlines()
def test_a_partial_trailing_line_is_truncated_at_startup(tmp_path):
# A host crash mid-write otherwise leaves a fragment that breaks every JSONL
# consumer downstream.
path = tmp_path / SIDECAR_NAME
path.write_text('{"message_id": 1}\n{"message_id": 2, "fi')
with Sidecar(tmp_path):
pass
assert lines(tmp_path) == ['{"message_id": 1}']
def test_a_complete_final_line_without_a_newline_is_kept(tmp_path):
path = tmp_path / SIDECAR_NAME
path.write_text('{"message_id": 1}\n{"message_id": 2}')
with Sidecar(tmp_path):
pass
assert [json.loads(line)["message_id"] for line in lines(tmp_path)] == [1, 2]
def test_an_intact_file_is_left_alone(tmp_path):
path = tmp_path / SIDECAR_NAME
path.write_text('{"message_id": 1}\n')
with Sidecar(tmp_path):
pass
assert lines(tmp_path) == ['{"message_id": 1}']
def test_an_empty_or_absent_file_is_not_a_repair_case(tmp_path):
with Sidecar(tmp_path):
pass
assert lines(tmp_path) == []
def test_rotation_preserves_the_old_records_under_a_timestamp(tmp_path):
(tmp_path / SIDECAR_NAME).write_text('{"message_id": 1}\n')
sidecar = Sidecar(tmp_path)
rotated = sidecar.rotate()
assert rotated is not None and rotated.exists()
assert rotated.name.startswith("messages-") and rotated.suffix == ".jsonl"
assert not (tmp_path / SIDECAR_NAME).exists()
# Rotation, not deletion: the current sidecar describes exactly one filter
# regime while the previous one stays inspectable.
assert json.loads(rotated.read_text())["message_id"] == 1
def test_rotating_a_missing_sidecar_is_a_no_op(tmp_path):
assert Sidecar(tmp_path).rotate() is None
def test_records_are_append_only_events_so_consumers_union_them(tmp_path):
"""Two runs under different filters both record message 1043. Reading only
the last record would report a narrower file list than what is on disk."""
msg_photo = FakeMsg(1043, kind="photo", grouped_id=8)
post = Post(post_id=1042, messages=[msg_photo], max_message_id=1043)
for _ in range(2):
with Sidecar(tmp_path) as sidecar:
sidecar.append(post, [])
sidecar.fsync()
records = [json.loads(line) for line in lines(tmp_path)]
assert [r["message_id"] for r in records] == [1043, 1043]
assert all(r["post_id"] == 1042 and r["grouped_id"] == 8 for r in records)
def test_a_text_only_message_gets_the_same_shape_with_no_files(tmp_path):
post = Post(post_id=12, messages=[FakeMsg(12, text="hello")], max_message_id=12)
with Sidecar(tmp_path) as sidecar:
sidecar.append(post, [])
sidecar.fsync()
record = json.loads(lines(tmp_path)[0])
assert record["files"] == [] and record["caption"] == "hello"
assert record["errors"] == []
def test_sender_name_is_only_taken_from_an_already_cached_sender(tmp_path):
# Never an extra get_entity call: on a 100k-message sweep that is 100k extra
# RPCs and a flood ban, in exchange for a display string.
class Sender:
first_name, last_name, username, title = "Alice", None, "alice", None
with_sender = FakeMsg(1, kind="photo", sender=Sender(), sender_id=777)
without = FakeMsg(2, kind="photo", sender=None, sender_id=778)
post = Post(post_id=1, messages=[with_sender, without], max_message_id=2)
with Sidecar(tmp_path) as sidecar:
sidecar.append(post, [])
sidecar.fsync()
records = [json.loads(line) for line in lines(tmp_path)]
assert records[0]["sender_name"] == "Alice"
assert records[1]["sender_name"] is None
assert records[1]["sender_id"] == 778
def test_a_partial_record_larger_than_the_scan_window_keeps_earlier_records(tmp_path):
# Scanning back a fixed window for the last newline finds none when the
# partial record is bigger than the window. Truncating to `end - window`
# then cuts inside the partial record and leaves the file just as unreadable,
# one window shorter - so the window has to grow until a newline is found.
path = tmp_path / SIDECAR_NAME
good = '{"message_id": 1}'
partial = '{"message_id": 2, "caption": "' + "x" * (2 << 20)
path.write_text(good + "\n" + partial)
with Sidecar(tmp_path):
pass
assert lines(tmp_path) == [good]
def test_two_rotations_in_the_same_second_both_stay_inspectable(tmp_path):
# os.replace onto a one-second stamp silently overwrote the first archive,
# which is the whole reason --reset-state rotates instead of deleting.
path = tmp_path / SIDECAR_NAME
path.write_text('{"message_id": 1}\n')
first = Sidecar(tmp_path).rotate()
path.write_text('{"message_id": 2}\n')
second = Sidecar(tmp_path).rotate()
assert first is not None and second is not None
assert first != second
assert first.exists() and second.exists()
assert json.loads(first.read_text())["message_id"] == 1
assert json.loads(second.read_text())["message_id"] == 2