fix(reader): the Takeout import keeps a later upload, an un-mark and its memory bound
- An ad-hoc request made while its target's run is under way is the run after it: the scheduler dropped a second fire of a running id, so a second Takeout file was never imported and its key stayed queued until a restart. - Un-marking or resetting a video is remembered (video_unmarks): an import of an older YouTube view no longer marks it watched again; un-marks take the user's Takeout lock. - One user's failure in the Shorts job's Takeout pass is theirs: the job keeps its own result, the others are settled, and a failed lookup spends none of the daily share. - The upload reads the user's id before giving the read connection back. - A watched row waits on a discovery video (search or explore) until the catalog keeps it, so a mark never pins it against the cleanup jobs. - The history is decoded a window at a time, a Watch later file over 2 MiB is refused unread, and two uploads at once across all users (takeout_server_busy). - A Watch later list goes in as one presence read and one insert.
This commit is contained in:
@@ -7,12 +7,15 @@ import tracemalloc
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import jobruns, quota, scheduler
|
||||
from app.db import SessionLocal
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.auth import current_user
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.catalog.models import Channel, Subscription, Video
|
||||
from app.models import JobRun, Notification, User
|
||||
from app.runstatus import RunStatus, RunTrigger
|
||||
from app.reader import queries, service, takeout
|
||||
@@ -145,6 +148,42 @@ def test_the_history_is_read_an_entry_at_a_time_not_as_one_tree():
|
||||
assert peak < 5 * len(body), f"parsing held {peak / len(body):.1f} times the file"
|
||||
|
||||
|
||||
def test_a_history_with_an_emoji_costs_less_than_its_own_size_to_read(monkeypatch):
|
||||
"""Decoded whole, one emoji makes the text four bytes a character: a 32 MiB history held some
|
||||
128 MiB of it on top of its bytes. Read a window at a time, it holds a window (a few times its
|
||||
bytes as text) whatever the file's size — a small one here, so a 9 MB file can show it."""
|
||||
monkeypatch.setattr(takeout, "_WINDOW", 64 * 1024)
|
||||
view = _view("aaaaaaaaaaa", title="Watched " + "😀" * 40)
|
||||
body = b"[" + b",".join([json.dumps(view, ensure_ascii=False).encode()] * 20_000) + b"]"
|
||||
tracemalloc.start()
|
||||
try:
|
||||
parsed = takeout.parse(body)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
assert parsed.entries == 20_000
|
||||
assert peak < len(body) / 2, f"parsing held {peak / len(body):.1f} times the file"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [1, 7, 100, 4096])
|
||||
def test_the_history_reads_the_same_whatever_its_window(monkeypatch, split):
|
||||
"""An entry, a character or a number cut by the window's edge is read whole."""
|
||||
monkeypatch.setattr(takeout, "_WINDOW", split)
|
||||
views = [_view(f"{c * 11}", title="ő😀" * 3) for c in "abcdef"]
|
||||
body = " [\n" + ",\n".join(json.dumps(v, ensure_ascii=False) for v in views) + "\n]\n"
|
||||
parsed = takeout.parse(body.encode())
|
||||
assert (parsed.entries, sorted(parsed.videos)) == (6, [c * 11 for c in "abcdef"])
|
||||
numbers = takeout._json_list(b"[123456789, 2]", 0)
|
||||
assert list(numbers) == [123456789, 2]
|
||||
|
||||
|
||||
def test_a_watch_later_file_far_larger_than_any_list_is_refused_unread():
|
||||
body = b"Video ID,Time\n" + b"x" * (takeout.CSV_MAX_BYTES)
|
||||
with pytest.raises(takeout.TakeoutError) as caught:
|
||||
takeout.parse(body)
|
||||
assert caught.value.reason == "too_many"
|
||||
|
||||
|
||||
def test_the_id_lists_are_asked_in_chunks(db, make_user, monkeypatch):
|
||||
user = make_user("chunks@example.com")
|
||||
ids = [f"chunk{n:06d}" for n in range(5)]
|
||||
@@ -253,6 +292,33 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o
|
||||
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("undo", ["unmark", "clear_state"])
|
||||
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo):
|
||||
"""Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one
|
||||
over the user's own choice. A view on YouTube after the un-mark is a new view, and counts."""
|
||||
user = make_user(f"{undo}@example.com")
|
||||
for vid in ("undone00000", "again000000"):
|
||||
_video(db, vid)
|
||||
db.commit()
|
||||
for vid in ("undone00000", "again000000"):
|
||||
service.mark(db, user, vid, "watched")
|
||||
getattr(service, undo)(db, user, vid)
|
||||
db.commit()
|
||||
assert _states(db, user) == {}
|
||||
later = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCHED,
|
||||
{"undone00000": (T0, None), "again000000": (later, None)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert takeout.run_import(user.id)["watched"] == 1
|
||||
assert set(_states(db, user)) == {"again000000"}
|
||||
assert _rows(db, user)["undone00000"].outcome == "kept"
|
||||
|
||||
|
||||
def test_a_second_upload_keeps_the_later_time_and_what_the_import_settled(db, make_user):
|
||||
user = make_user("again@example.com")
|
||||
_video(db, "plain000000")
|
||||
@@ -305,6 +371,40 @@ def test_the_watch_later_list_joins_the_users_own_oldest_first_after_what_it_hol
|
||||
assert db.execute(select(Playlist).where(Playlist.user_id == user.id)).scalars().all() == [pl]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n", [3, 40])
|
||||
def test_a_watch_later_list_goes_in_in_statements_that_do_not_grow_with_it(db, make_user, n):
|
||||
"""A video at a time, a full list of 5000 was some 20,000 statements under the user's lock."""
|
||||
from sqlalchemy import event
|
||||
|
||||
user = make_user(f"many{n}@example.com")
|
||||
ids = [f"wl{i:09d}" for i in range(n)]
|
||||
for vid in ids:
|
||||
_video(db, vid)
|
||||
db.commit()
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCH_LATER,
|
||||
{v: (T0 + timedelta(minutes=i), None) for i, v in enumerate(ids)},
|
||||
)
|
||||
db.commit()
|
||||
rows = queries.takeout_waiting(db, user, TakeoutKind.WATCH_LATER)
|
||||
service.watch_later(db, user) # the list made beforehand: its own insert is not this count's
|
||||
db.flush()
|
||||
statements = []
|
||||
engine = db.get_bind()
|
||||
listener = lambda *a: statements.append(a[2]) # noqa: E731 — the event's own signature
|
||||
event.listen(engine, "before_cursor_execute", listener)
|
||||
try:
|
||||
assert service.apply_takeout_watch_later(db, user, rows, set())["watch_later_added"] == n
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", listener)
|
||||
db.commit()
|
||||
assert len(statements) <= 9, statements
|
||||
held = [i.video_id for i in queries.items(db, service.watch_later(db, user).id)]
|
||||
assert held == ids
|
||||
|
||||
|
||||
def test_the_job_fetches_the_unknown_watch_later_videos_and_says_what_waits(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
@@ -407,6 +507,38 @@ def test_the_shorts_job_puts_in_the_watch_later_videos_a_run_could_not(db, make_
|
||||
assert takeout.apply_waiting(db) == {}
|
||||
|
||||
|
||||
def test_one_users_failure_in_the_shorts_job_leaves_the_others_and_the_probe_alone(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
"""A Watch later lookup that raised (a revoked token, a refused quota) escaped the Shorts job:
|
||||
its run was recorded as failed with its own counts lost, and every user after this one waited."""
|
||||
first, second = make_user("first@example.com"), make_user("second@example.com")
|
||||
_video(db, "ready000000")
|
||||
db.commit()
|
||||
service.stage_takeout(db, first, TakeoutKind.WATCH_LATER, {"unknown0000": (T0, None)})
|
||||
service.stage_takeout(db, second, TakeoutKind.WATCHED, {"ready000000": (T0, None)})
|
||||
db.commit()
|
||||
|
||||
def refused(session, yt, ids):
|
||||
raise RuntimeError("invalid_grant")
|
||||
|
||||
monkeypatch.setattr(takeout, "ensure_videos", refused)
|
||||
monkeypatch.setattr(takeout, "get_service_user", lambda session: first)
|
||||
monkeypatch.setattr(scheduler, "run_shorts", lambda session: {"probed": 4})
|
||||
|
||||
scheduler._shorts_job()
|
||||
|
||||
db.expire_all()
|
||||
run = db.execute(select(JobRun).where(JobRun.job == "shorts")).scalar_one()
|
||||
assert run.status == "ok"
|
||||
assert run.result == {"probed": 4, "takeout_watched": 1, "takeout_failed": 1}
|
||||
assert _states(db, second)["ready000000"].status == "watched"
|
||||
assert _rows(db, first)["WL:unknown0000"].outcome is None
|
||||
# A lookup that failed spent none of the user's daily share.
|
||||
assert not takeout._lookups.exhausted(str(first.id))
|
||||
assert takeout._lookups.room(str(first.id)) == 2 * takeout.WATCH_LATER_MAX
|
||||
|
||||
|
||||
def test_a_watch_later_row_staged_while_youtube_is_asked_is_left_for_the_next_run(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
@@ -469,6 +601,9 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user):
|
||||
"watch later": lambda s, u: service.apply_takeout_watch_later(
|
||||
s, u, [(1, "aaaaaaaaaaa")], set()
|
||||
),
|
||||
# Between the import's read of the un-marks and its marking, one would be overwritten.
|
||||
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
|
||||
"reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"),
|
||||
}
|
||||
with SessionLocal() as holder:
|
||||
service.lock_takeout(holder, holder.get(User, user.id))
|
||||
@@ -570,6 +705,39 @@ def test_a_signed_in_upload_has_its_body_read(db, make_user, as_user, queued):
|
||||
assert len(pulled) == 4
|
||||
|
||||
|
||||
def test_the_upload_arrives_with_no_connection_held(
|
||||
db, make_user, as_user, override_dep, queued, monkeypatch
|
||||
):
|
||||
"""The read connection is given back before the body: reading the user's id after that reloaded
|
||||
the user and took one straight back out, `idle in transaction` for the whole upload."""
|
||||
user = make_user("idle@example.com")
|
||||
client = as_user(user)
|
||||
|
||||
def signed_in(session: Session = Depends(get_db)) -> User:
|
||||
# As the real sign-in check: a SELECT on the request's session, which the release ends —
|
||||
# and expires what it loaded. (`as_user`'s own resolver reads nothing.)
|
||||
session.expire_all()
|
||||
return session.get(User, user.id)
|
||||
|
||||
override_dep(current_user, signed_in)
|
||||
sessions, held = [], []
|
||||
release, capped = feed_routes.release_read_connection, feed_routes.capped_body
|
||||
|
||||
def releasing(session):
|
||||
sessions.append(session)
|
||||
release(session)
|
||||
|
||||
async def reading(request, limit):
|
||||
held.extend(s.in_transaction() for s in sessions)
|
||||
return await capped(request, limit)
|
||||
|
||||
monkeypatch.setattr(feed_routes, "release_read_connection", releasing)
|
||||
monkeypatch.setattr(feed_routes, "capped_body", reading)
|
||||
resp = client.post("/api/takeout", content=_history(_view("aaaaaaaaaaa")))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert held == [False]
|
||||
|
||||
|
||||
def test_uploads_past_the_hourly_limit_are_refused_after_their_body_arrived(
|
||||
db, make_user, as_user, queued
|
||||
):
|
||||
@@ -600,6 +768,20 @@ def test_a_second_upload_while_one_of_the_users_is_still_arriving_is_refused(
|
||||
assert feed_routes._takeout_uploading == {user.id}
|
||||
|
||||
|
||||
def test_a_third_upload_across_users_while_two_arrive_is_refused_after_its_body(
|
||||
db, make_user, as_user, queued, monkeypatch
|
||||
):
|
||||
"""Each upload's bytes are held whole while it arrives: with no bound across users, a few at
|
||||
once could take the process past its memory."""
|
||||
monkeypatch.setattr(feed_routes, "_takeout_uploading", {9001, 9002})
|
||||
body, pulled = _counting_body()
|
||||
resp = as_user(make_user("third@example.com")).post("/api/takeout", content=body)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"]["reason"] == "takeout_server_busy"
|
||||
assert len(pulled) == 4
|
||||
assert queued == [] and feed_routes._takeout_uploading == {9001, 9002}
|
||||
|
||||
|
||||
def test_an_upload_that_would_keep_too_many_videos_is_refused(
|
||||
db, make_user, as_user, queued, monkeypatch
|
||||
):
|
||||
@@ -679,12 +861,49 @@ def test_a_queued_import_is_told_apart_from_the_last_one_until_its_run_is_record
|
||||
assert scheduler.ad_hoc_queued("takeout_import", target)
|
||||
assert as_user(user).get("/api/takeout").json()["queued"] is True
|
||||
|
||||
scheduler._ad_hoc("takeout_import", dict, user.id, target)
|
||||
scheduler._ad_hoc_fired("takeout_import", target)
|
||||
assert not scheduler.ad_hoc_queued("takeout_import", target)
|
||||
assert as_user(user).get("/api/takeout").json()["queued"] is False
|
||||
assert added == [f"takeout_import@{target}"]
|
||||
|
||||
|
||||
def test_an_upload_while_its_import_starts_stays_queued_for_the_run_after(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
"""A request landing while the run before it writes its row is the run after that one: still
|
||||
queued, so the page does not take the run starting now for it."""
|
||||
user = make_user("starting@example.com")
|
||||
added, ran = [], []
|
||||
|
||||
class Held:
|
||||
def add_job(self, *args, **kwargs):
|
||||
added.append(kwargs["id"])
|
||||
|
||||
monkeypatch.setattr(scheduler, "_scheduler", Held())
|
||||
target = takeout.run_target(user.id)
|
||||
start = jobruns.start
|
||||
|
||||
def starting(*args, **kwargs):
|
||||
scheduler.run_ad_hoc(
|
||||
"takeout_import", lambda: ran.append(2) or {}, actor_id=user.id, target=target
|
||||
)
|
||||
return start(*args, **kwargs)
|
||||
|
||||
scheduler.run_ad_hoc(
|
||||
"takeout_import", lambda: ran.append(1) or {}, actor_id=user.id, target=target
|
||||
)
|
||||
monkeypatch.setattr(scheduler.jobruns, "start", starting)
|
||||
scheduler._ad_hoc_fired("takeout_import", target)
|
||||
assert ran == [1]
|
||||
assert scheduler.ad_hoc_queued("takeout_import", target)
|
||||
assert added == [f"takeout_import@{target}"] * 2 # the run after it, asked for as it ended
|
||||
|
||||
monkeypatch.setattr(scheduler.jobruns, "start", start)
|
||||
scheduler._ad_hoc_fired("takeout_import", target)
|
||||
assert ran == [1, 2]
|
||||
assert not scheduler.ad_hoc_queued("takeout_import", target)
|
||||
|
||||
|
||||
# --- the videos that wait ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -711,6 +930,39 @@ def test_waiting_videos_are_marked_once_the_shorts_probe_has_cleared_them(db, ma
|
||||
assert takeout.apply_waiting(db) == {}
|
||||
|
||||
|
||||
def test_a_discovery_video_is_not_marked_until_the_catalog_keeps_it(db, make_user):
|
||||
"""A mark is a VideoState, which the cleanup jobs read as a reader keeping the video: on a video
|
||||
only someone's search or explore brought in, it kept that video (and its channel) for good."""
|
||||
user = make_user("discover@example.com")
|
||||
for vid, flag in (("search00000", "via_search"), ("explore0000", "via_explore")):
|
||||
_video(db, vid)
|
||||
db.flush()
|
||||
setattr(db.get(Video, vid), flag, True)
|
||||
_video(db, "plain000000")
|
||||
db.commit()
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCHED,
|
||||
{v: (T0, None) for v in ("search00000", "explore0000", "plain000000")},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert takeout.apply_waiting(db) == {"takeout_watched": 1}
|
||||
assert set(_states(db, user)) == {"plain000000"}
|
||||
assert {v for v, r in _rows(db, user).items() if r.outcome is None} == {
|
||||
"search00000",
|
||||
"explore0000",
|
||||
}
|
||||
|
||||
# Subscribed to: the catalog keeps the channel's videos of its own, the search one included.
|
||||
db.add(Subscription(user_id=user.id, channel_id=CHANNEL))
|
||||
db.get(Video, "explore0000").via_explore = False # as subscribing does (catalog.service)
|
||||
db.commit()
|
||||
assert takeout.apply_waiting(db) == {"takeout_watched": 2}
|
||||
assert set(_states(db, user)) == {"plain000000", "search00000", "explore0000"}
|
||||
|
||||
|
||||
def test_the_shorts_job_settles_the_waiting_rows_its_probe_made_ready(db, make_user):
|
||||
user = make_user("job-shorts@example.com")
|
||||
_video(db, "ready000000") # probed already: the job's probe has nothing left to do
|
||||
|
||||
Reference in New Issue
Block a user