Files
siftlode/backend/tests/test_takeout.py
T
peter 9c5f81fd94 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.
2026-10-23 22:07:11 +02:00

1020 lines
40 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The Google Takeout import: reading the two files of the export, keeping their videos, and what the
import job makes of them — watched here (never a Short, never over a state the user set), waiting for
a video the catalog does not know yet, and the Watch later list joined to the user's own."""
import json
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.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
from app.routes import feed as feed_routes
from app.reader.models import (
Playlist,
PlaylistItem,
TakeoutItem,
TakeoutKind,
VideoState,
)
T0 = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
CHANNEL = "UC" + "a" * 22
def _view(vid: str, at: datetime = T0, *, channel: str | None = CHANNEL, host="www", **extra):
entry = {
"header": "YouTube",
"title": "Megnézted: valami", # the account's language: never read
"titleUrl": f"https://{host}.youtube.com/watch?v={vid}",
"time": at.isoformat().replace("+00:00", "Z"),
"products": ["YouTube"],
"activityControls": ["YouTube watch history"],
}
if channel:
entry["subtitles"] = [{"name": "Ch", "url": f"https://www.youtube.com/channel/{channel}"}]
return entry | extra
def _history(*entries) -> bytes:
return json.dumps(list(entries)).encode()
# --- reading the files -------------------------------------------------------------------------
def test_the_history_keeps_each_video_once_at_its_last_view_with_its_channel():
later = T0 + timedelta(days=3)
parsed = takeout.parse(
_history(
_view("aaaaaaaaaaa", later),
_view("aaaaaaaaaaa", T0),
_view("bbbbbbbbbbb", T0, channel=None), # a video since removed names no channel
_view("ccccccccccc", T0, host="music"),
# Out of order: the later view still wins.
_view("ddddddddddd", T0, channel=None),
_view("ddddddddddd", later),
)
)
assert parsed.kind == TakeoutKind.WATCHED
assert parsed.entries == 6
assert parsed.videos == {
"aaaaaaaaaaa": (later, CHANNEL),
"bbbbbbbbbbb": (T0, None),
"ccccccccccc": (T0, CHANNEL),
"ddddddddddd": (later, CHANNEL),
}
def test_the_history_counts_what_it_leaves_out():
ad = _view("ddddddddddd", details=[{"name": "From Google Ads"}])
post = _view("eeeeeeeeeee") | {"titleUrl": "https://www.youtube.com/post/Ugkx123"}
no_link = {k: v for k, v in _view("fffffffffff").items() if k != "titleUrl"}
bad_time = _view("ggggggggggg") | {"time": "yesterday"}
no_ms = _view("hhhhhhhhhhh") | {"time": "2025-04-19T21:29:22Z"}
parsed = takeout.parse(_history(ad, post, no_link, bad_time, no_ms, "junk"))
assert (parsed.ads, parsed.other, parsed.malformed) == (1, 2, 2)
assert parsed.videos == {
"hhhhhhhhhhh": (datetime(2025, 4, 19, 21, 29, 22, tzinfo=timezone.utc), CHANNEL)
}
def test_the_watch_later_csv_is_read_with_a_header_in_any_language_or_none():
body = "Videóazonosító,Létrehozás ideje\nNgb04xUK8uU,2019-02-07T15:23:30+00:00\nbad,x\n"
parsed = takeout.parse(("" + body).encode())
assert parsed.kind == TakeoutKind.WATCH_LATER
assert parsed.videos == {
"Ngb04xUK8uU": (datetime(2019, 2, 7, 15, 23, 30, tzinfo=timezone.utc), None)
}
assert parsed.malformed == 1
headless = takeout.parse(b"Ngb04xUK8uU,2019-02-07T15:23:30+00:00\n")
assert list(headless.videos) == ["Ngb04xUK8uU"]
@pytest.mark.parametrize(
("body", "reason"),
[
(b'{"some": "other", "export": "file"}', "not_takeout"),
(b"<html><body>watch history, as a page</body></html>", "not_takeout"),
(b"[not json", "not_takeout"),
(b"\xff\xfe\x00", "not_takeout"),
(b"one column only\n", "not_takeout"),
(b"[]", "no_videos"),
(
json.dumps([_view("aaaaaaaaaaa", details=[{"name": "From Google Ads"}])]).encode(),
"no_videos",
),
# Read an entry at a time, the list must still be one: nothing after it, nothing missing.
(_history(_view("aaaaaaaaaaa")) + b" []", "not_takeout"),
(_history(_view("aaaaaaaaaaa"))[:-1], "not_takeout"),
(b"[" + json.dumps(_view("aaaaaaaaaaa")).encode() + b" x]", "not_takeout"),
# However a hostile file fails, it is one the import cannot read, not a server error.
(b"[" * 100_000, "not_takeout"),
(b"[" + b"1" * 5000 + b"]", "not_takeout"),
(b"Ngb04xUK8uU," + b"x" * 200_000 + b"\n", "not_takeout"),
(
"".join(f"v{n:010d},2019-02-07T15:23:30Z\n" for n in range(5001)).encode(),
"too_many",
),
],
)
def test_a_file_the_import_cannot_use_is_refused_with_its_reason(body, reason):
with pytest.raises(takeout.TakeoutError) as caught:
takeout.parse(body)
assert caught.value.reason == reason
def test_the_history_is_read_an_entry_at_a_time_not_as_one_tree():
"""A file of a million tiny entries must cost about its own size, not a million dicts at once:
the whole tree built first measured ~25 times the file."""
body = b"[" + b",".join([b"{}"] * 1_000_000) + b"]" # 3 MB
tracemalloc.start()
try:
with pytest.raises(takeout.TakeoutError):
takeout.parse(body)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
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)]
for vid in ids[:3]:
_video(db, vid)
db.commit()
service.stage_takeout(db, user, TakeoutKind.WATCHED, {vid: (T0, None) for vid in ids[:4]})
db.commit()
monkeypatch.setattr(queries, "_IN_CHUNK", 2)
assert queries.known_video_ids(db, ids) == set(ids[:3])
assert queries.takeout_rows_besides(db, user, TakeoutKind.WATCHED, ids) == 0
assert queries.takeout_rows_besides(db, user, TakeoutKind.WATCHED, ids[:1]) == 3
def test_the_history_is_read_whatever_its_whitespace():
body = b" \n[ \n" + json.dumps(_view("aaaaaaaaaaa")).encode() + b" ,\n\t"
body += json.dumps(_view("bbbbbbbbbbb")).encode() + b"\n] \n"
assert sorted(takeout.parse(body).videos) == ["aaaaaaaaaaa", "bbbbbbbbbbb"]
full = "".join(f"v{n:010d},2019-02-07T15:23:30Z\n" for n in range(5000))
assert len(takeout.parse(full.encode()).videos) == 5000 # YouTube's own cap still fits
# --- keeping and applying ----------------------------------------------------------------------
def _video(db, vid: str, *, probed=True, short=False):
if db.get(Channel, CHANNEL) is None:
db.add(Channel(id=CHANNEL, title="Ch"))
db.flush()
db.add(Video(id=vid, channel_id=CHANNEL, published_at=T0, shorts_probed=probed, is_short=short))
def _state(db, user, vid, status, *, changed, position=0, watched_at=None):
db.add(
VideoState(
user_id=user.id,
video_id=vid,
status=status,
position_seconds=position,
watched_at=watched_at,
updated_at=changed,
)
)
def _rows(db, user) -> dict[str, TakeoutItem]:
db.expire_all()
rows = db.execute(select(TakeoutItem).where(TakeoutItem.user_id == user.id)).scalars()
return {(r.video_id if r.kind == TakeoutKind.WATCHED else "WL:" + r.video_id): r for r in rows}
def _states(db, user) -> dict[str, VideoState]:
db.expire_all()
rows = db.execute(select(VideoState).where(VideoState.user_id == user.id)).scalars()
return {r.video_id: r for r in rows}
def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_or_are_kept(
db, make_user
):
user = make_user("takeout@example.com")
for vid in ("plain000000", "short000000", "unprobed000", "hidden00000", "watched0000"):
_video(db, vid, probed=vid != "unprobed000", short=vid == "short000000")
for vid in ("newer000000", "older000000"):
_video(db, vid)
earlier = T0 - timedelta(days=30)
_state(db, user, "hidden00000", "hidden", changed=earlier)
_state(db, user, "watched0000", "watched", changed=earlier, watched_at=earlier)
# In progress here, touched after the YouTube view: what the user did here is newer.
_state(db, user, "newer000000", "new", changed=T0 + timedelta(hours=1), position=40)
# In progress here, but last touched before the YouTube view: they finished it there.
_state(db, user, "older000000", "new", changed=earlier, position=40)
db.commit()
ids = [
"plain000000",
"short000000",
"unprobed000",
"unknown0000",
"hidden00000",
"watched0000",
"newer000000",
"older000000",
]
service.stage_takeout(db, user, TakeoutKind.WATCHED, {v: (T0, CHANNEL) for v in ids})
counts = service.apply_takeout_watched(db, user)
db.commit()
assert counts == {"watched": 2, "kept": 3, "shorts": 1}
rows, states = _rows(db, user), _states(db, user)
assert {v: rows[v].outcome for v in ids} == {
"plain000000": "watched",
"short000000": "short",
"unprobed000": None,
"unknown0000": None,
"hidden00000": "kept",
"watched0000": "kept",
"newer000000": "kept",
"older000000": "watched",
}
assert (states["plain000000"].status, states["plain000000"].watched_at) == ("watched", T0)
assert "short000000" not in states
assert states["hidden00000"].status == "hidden"
assert states["watched0000"].watched_at == earlier
assert (states["newer000000"].status, states["newer000000"].position_seconds) == ("new", 40)
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")
db.commit()
service.stage_takeout(db, user, TakeoutKind.WATCHED, {"plain000000": (T0, None)})
service.apply_takeout_watched(db, user)
later = T0 + timedelta(days=1)
service.stage_takeout(db, user, TakeoutKind.WATCHED, {"plain000000": (later, CHANNEL)})
service.stage_takeout(db, user, TakeoutKind.WATCHED, {"plain000000": (T0, None)})
db.commit()
row = _rows(db, user)["plain000000"]
assert (row.at, row.channel_id, row.outcome) == (later, CHANNEL, "watched")
def test_the_watch_later_list_joins_the_users_own_oldest_first_after_what_it_holds(db, make_user):
user = make_user("later@example.com")
for vid in ("held0000000", "old00000000", "new00000000"):
_video(db, vid)
db.commit()
pl = service.watch_later(db, user)
service.add_item(db, pl, "held0000000", mark_dirty=False)
service.stage_takeout(
db,
user,
TakeoutKind.WATCH_LATER,
{
"new00000000": (T0, None),
"held0000000": (T0, None),
"old00000000": (T0 - timedelta(days=9), None),
"gone0000000": (T0, None), # YouTube no longer has it
},
)
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"unasked0000": (T0, None)})
rows = queries.takeout_waiting(db, user, TakeoutKind.WATCH_LATER)
counts = service.apply_takeout_watch_later(db, user, rows, asked={"gone0000000"})
db.commit()
assert counts == {"watch_later_added": 2, "watch_later_present": 1, "watch_later_missing": 1}
# Unknown and not asked about: it waits, it is not called gone.
assert _rows(db, user)["WL:unasked0000"].outcome is None
items = db.execute(
select(PlaylistItem.video_id)
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
).scalars()
assert list(items) == ["held0000000", "old00000000", "new00000000"]
assert _rows(db, user)["WL:gone0000000"].outcome == "missing"
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
):
user = make_user("job@example.com")
_video(db, "plain000000")
db.commit()
service.stage_takeout(
db,
user,
TakeoutKind.WATCHED,
{"plain000000": (T0, CHANNEL), "unknown0000": (T0, CHANNEL)},
)
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"fetched0000": (T0, None)})
db.commit()
asked = []
def fake_ensure(session, yt, ids):
# The lookup is the importing user's spend, on the import's own action.
asked.append((ids, quota._actor_id.get(), quota._action.get()))
_video(session, "fetched0000")
session.flush()
monkeypatch.setattr(takeout, "ensure_videos", fake_ensure)
monkeypatch.setattr(takeout, "get_service_user", lambda session: user)
result = takeout.run_import(user.id)
assert asked == [(["fetched0000"], user.id, quota.QuotaAction.VIDEOS_LOOKUP)]
assert result == {
"watched": 1,
"kept": 0,
"shorts": 0,
"watch_later_added": 1,
"watch_later_present": 0,
"watch_later_missing": 0,
"waiting": 1,
}
def _wl_job(db, make_user, monkeypatch, email, *, fetcher=True, reserve=False):
user = make_user(email)
_video(db, "known000000")
db.commit()
service.stage_takeout(
db,
user,
TakeoutKind.WATCH_LATER,
{"known000000": (T0, None), "unknown0000": (T0 + timedelta(minutes=1), None)},
)
db.commit()
asked = []
monkeypatch.setattr(takeout, "ensure_videos", lambda session, yt, ids: asked.append(ids))
monkeypatch.setattr(takeout, "get_service_user", lambda session: user if fetcher else None)
monkeypatch.setattr(takeout.quota, "backfill_reserve_reached", lambda: reserve)
return user, asked
@pytest.mark.parametrize(
"why", ["no account to ask with", "the budget's reserve", "the user's share"]
)
def test_a_watch_later_video_not_asked_about_waits_and_the_known_ones_go_in(
db, make_user, monkeypatch, why
):
user, asked = _wl_job(
db,
make_user,
monkeypatch,
"wait@example.com",
fetcher=why != "no account to ask with",
reserve=why == "the budget's reserve",
)
if why == "the user's share":
monkeypatch.setattr(takeout, "_lookups", takeout.RateLimiter(0, 3600))
result = takeout.run_import(user.id)
assert asked == []
assert (result["watch_later_added"], result["watch_later_missing"]) == (1, 0)
assert result["watch_later_waiting"] == 1
assert _rows(db, user)["WL:unknown0000"].outcome is None
def test_the_shorts_job_puts_in_the_watch_later_videos_a_run_could_not(db, make_user, monkeypatch):
user, asked = _wl_job(db, make_user, monkeypatch, "retry@example.com", fetcher=False)
takeout.run_import(user.id)
assert asked == []
# A YouTube-connected account exists now; the next probe run asks for what waits.
monkeypatch.setattr(takeout, "get_service_user", lambda session: user)
def fetched(session, yt, ids):
asked.append(ids)
_video(session, "unknown0000")
session.flush()
monkeypatch.setattr(takeout, "ensure_videos", fetched)
assert takeout.apply_waiting(db) == {"takeout_watch_later_added": 1}
assert asked == [["unknown0000"]]
assert _rows(db, user)["WL:unknown0000"].outcome == "added"
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
):
user, _ = _wl_job(db, make_user, monkeypatch, "race@example.com")
def meanwhile(session, yt, ids):
# A second upload lands while the lookup runs: its video was never asked about.
with SessionLocal() as other:
service.stage_takeout(
other,
other.get(User, user.id),
TakeoutKind.WATCH_LATER,
{"second00000": (T0, None)},
)
other.commit()
monkeypatch.setattr(takeout, "ensure_videos", meanwhile)
takeout.run_import(user.id)
rows = _rows(db, user)
assert rows["WL:unknown0000"].outcome == "missing" # asked, and YouTube has it no longer
assert rows["WL:second00000"].outcome is None
def test_a_youtube_failure_keeps_what_the_watched_part_already_marked(db, make_user, monkeypatch):
user = make_user("fails@example.com")
_video(db, "plain000000")
db.commit()
service.stage_takeout(db, user, TakeoutKind.WATCHED, {"plain000000": (T0, CHANNEL)})
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"unknown0000": (T0, None)})
db.commit()
def refused(session, yt, ids):
raise RuntimeError("quota exceeded")
monkeypatch.setattr(takeout, "ensure_videos", refused)
monkeypatch.setattr(takeout, "get_service_user", lambda session: user)
with pytest.raises(RuntimeError):
takeout.run_import(user.id)
with SessionLocal() as fresh:
state = fresh.execute(select(VideoState).where(VideoState.user_id == user.id)).scalar_one()
assert (state.video_id, state.status) == ("plain000000", "watched")
outcome = fresh.execute(
select(TakeoutItem.outcome).where(TakeoutItem.video_id == "plain000000")
).scalar_one()
assert outcome == "watched"
def test_staging_and_settling_take_the_users_one_lock(db, make_user):
"""An upload's staging and an import's settling lock the same rows in no common order: one lock
over both is what keeps the two from deadlocking."""
user = make_user("lock@example.com")
db.commit()
steps = {
"stage": lambda s, u: service.stage_takeout(
s, u, TakeoutKind.WATCHED, {"aaaaaaaaaaa": (T0, None)}
),
"watched": lambda s, u: service.apply_takeout_watched(s, u),
"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))
for name, step in steps.items():
with SessionLocal() as other:
other.execute(text("SET lock_timeout = '200ms'"))
with pytest.raises(OperationalError, match="lock timeout"):
step(other, other.get(User, user.id))
pytest.fail(f"{name} did not wait for the user's lock")
holder.rollback()
# --- the upload --------------------------------------------------------------------------------
@pytest.fixture
def queued(monkeypatch):
runs = []
monkeypatch.setattr(
scheduler, "run_ad_hoc", lambda job, fn, **kw: runs.append((job, fn.func, fn.args, kw))
)
return runs
def test_an_upload_keeps_its_videos_and_queues_the_users_import_with_a_notice(
db, make_user, as_user, queued
):
user = make_user("upload@example.com")
body = _history(
_view("aaaaaaaaaaa"), _view("bbbbbbbbbbb", details=[{"name": "From Google Ads"}])
)
resp = as_user(user).post("/api/takeout", content=body)
assert resp.status_code == 200, resp.text
assert resp.json() == {
"ok": True,
"kind": "watched",
"videos": 1,
"entries": 2,
"ads": 1,
"other": 0,
"malformed": 0,
}
assert list(_rows(db, user)) == ["aaaaaaaaaaa"]
assert queued == [
(
"takeout_import",
takeout.run_import,
(user.id,),
{"actor_id": user.id, "target": f"user:{user.id}", "notify": True},
)
]
@pytest.mark.parametrize(
("body", "reason"),
[(b"<html></html>", "takeout_unreadable"), (b"[]", "takeout_no_videos")],
)
def test_an_upload_the_import_cannot_use_is_refused_in_the_readers_language(
db, make_user, as_user, queued, body, reason
):
user = make_user("refused@example.com")
resp = as_user(user).post("/api/takeout", content=body)
assert resp.status_code == 422
assert resp.json()["detail"] == {"code": "refused", "reason": reason}
assert queued == [] and _rows(db, user) == {}
def test_an_upload_over_the_cap_is_refused_while_it_arrives(db, make_user, as_user, monkeypatch):
user = make_user("big@example.com")
monkeypatch.setattr(takeout, "MAX_BYTES", 64)
resp = as_user(user).post("/api/takeout", content=_history(_view("aaaaaaaaaaa")))
assert resp.status_code == 413
assert _rows(db, user) == {}
def _counting_body(chunks: int = 4, size: int = 1024):
"""A body that records how much of itself the server pulled (test_upload.py's probe)."""
pulled: list[int] = []
def body():
for _ in range(chunks):
pulled.append(size)
yield b"x" * size
return body(), pulled
def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
body, pulled = _counting_body()
assert client.post("/api/takeout", content=body).status_code == 401
assert pulled == [], f"the server read {sum(pulled)} bytes from an anonymous caller"
def test_a_signed_in_upload_has_its_body_read(db, make_user, as_user, queued):
"""The mutation check for the test above: a route reading nobody's body would pass it too."""
body, pulled = _counting_body()
c = as_user(make_user("read@example.com"))
assert c.post("/api/takeout", content=body).status_code == 422
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
):
c = as_user(make_user("many@example.com"))
for _ in range(10):
assert c.post("/api/takeout", content=_history(_view("aaaaaaaaaaa"))).status_code == 200
body, pulled = _counting_body()
resp = c.post("/api/takeout", content=body)
assert resp.status_code == 429
assert resp.json()["detail"] == {"code": "refused", "reason": "takeout_cooldown"}
# Read to the end first: refused mid-upload, the browser would say "network error" instead.
assert len(pulled) == 4
assert len(queued) == 10
def test_a_second_upload_while_one_of_the_users_is_still_arriving_is_refused(
db, make_user, as_user, queued, monkeypatch
):
user = make_user("busy@example.com")
monkeypatch.setattr(feed_routes, "_takeout_uploading", {user.id})
resp = as_user(user).post("/api/takeout", content=_history(_view("aaaaaaaaaaa")))
assert resp.status_code == 429
assert resp.json()["detail"]["reason"] == "takeout_busy"
assert queued == []
# Another user's upload is not held up by it, and lets go of its own place when it is done.
other = as_user(make_user("free@example.com"))
assert other.post("/api/takeout", content=_history(_view("aaaaaaaaaaa"))).status_code == 200
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
):
user = make_user("cap@example.com")
c = as_user(user)
monkeypatch.setattr(takeout, "ROWS_MAX", 3)
two = _history(_view("aaaaaaaaaaa"), _view("bbbbbbbbbbb"))
assert c.post("/api/takeout", content=two).status_code == 200
assert c.post("/api/takeout", content=two).status_code == 200 # the same videos again: no more
resp = c.post("/api/takeout", content=_history(_view("ccccccccccc"), _view("ddddddddddd")))
assert resp.status_code == 422
assert resp.json()["detail"] == {"code": "refused", "reason": "takeout_too_many", "limit": 3}
assert sorted(_rows(db, user)) == ["aaaaaaaaaaa", "bbbbbbbbbbb"]
assert len(queued) == 2
def test_a_watch_later_file_longer_than_youtube_allows_is_refused(db, make_user, as_user, queued):
body = "".join(f"v{n:010d},2019-02-07T15:23:30Z\n" for n in range(5001)).encode()
resp = as_user(make_user("long@example.com")).post("/api/takeout", content=body)
assert resp.status_code == 422
assert resp.json()["detail"] == {
"code": "refused",
"reason": "takeout_list_too_long",
"limit": 5000,
}
# --- the notice --------------------------------------------------------------------------------
def test_an_ad_hoc_run_that_asks_for_it_tells_its_user_how_it_went(db, make_user):
user = make_user("notice@example.com")
scheduler._ad_hoc("takeout_import", lambda: {"watched": 3}, user.id, "user:1", True)
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_x")
db.expire_all()
notices = db.execute(select(Notification).where(Notification.user_id == user.id)).scalars()
assert [(n.data["job_id"], n.data["result"]) for n in notices] == [
("takeout_import", {"watched": 3})
]
def test_a_failed_run_tells_its_user_the_failure_never_the_upstreams_text(db, make_user):
user = make_user("failnotice@example.com")
def fails():
raise RuntimeError("(psycopg.errors.Something) [SQL: SELECT secret]")
scheduler._ad_hoc("takeout_import", fails, user.id, "user:1", True)
scheduler._ad_hoc("channel_backfill", fails, user.id, "UC_x")
db.expire_all()
(notice,) = db.execute(select(Notification).where(Notification.user_id == user.id)).scalars()
assert notice.body is None
assert notice.data == {
"job_id": "takeout_import",
"status": "error",
"summary": None,
"result": {"failure": "unexpected"},
}
# The admin's run row keeps the diagnostic.
run = db.execute(select(JobRun).where(JobRun.job == "takeout_import")).scalar_one()
assert "SELECT secret" in run.error
def test_a_queued_import_is_told_apart_from_the_last_one_until_its_run_is_recorded(
db, make_user, as_user, monkeypatch
):
user = make_user("queued@example.com")
added = []
class Held:
def add_job(self, *args, **kwargs):
added.append(kwargs["id"])
monkeypatch.setattr(scheduler, "_scheduler", Held())
target = takeout.run_target(user.id)
scheduler.run_ad_hoc("takeout_import", dict, actor_id=user.id, target=target)
assert scheduler.ad_hoc_queued("takeout_import", target)
assert as_user(user).get("/api/takeout").json()["queued"] is True
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 ----------------------------------------------------------------------
def test_waiting_videos_are_marked_once_the_shorts_probe_has_cleared_them(db, make_user):
a, b = make_user("a@example.com"), make_user("b@example.com")
_video(db, "later000000", probed=False)
_video(db, "notyet00000", probed=False)
db.commit()
service.stage_takeout(
db, a, TakeoutKind.WATCHED, {"later000000": (T0, None), "notyet00000": (T0, None)}
)
service.stage_takeout(db, b, TakeoutKind.WATCHED, {"later000000": (T0, None)})
db.commit()
assert takeout.apply_waiting(db) == {}
# The probe clears one of them; the other is still unprobed.
db.get(Video, "later000000").shorts_probed = True
db.commit()
assert takeout.apply_waiting(db) == {"takeout_watched": 2}
assert _states(db, a)["later000000"].watched_at == T0
assert _states(db, b)["later000000"].status == "watched"
assert _rows(db, a)["notyet00000"].outcome is None
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
db.commit()
service.stage_takeout(db, user, TakeoutKind.WATCHED, {"ready000000": (T0, None)})
db.commit()
scheduler._shorts_job()
db.expire_all()
run = db.execute(select(JobRun).where(JobRun.job == "shorts")).scalar_one()
assert run.result == {"probed": 0, "shorts": 0, "takeout_watched": 1}
assert _states(db, user)["ready000000"].status == "watched"
# --- what the settings page reads --------------------------------------------------------------
def test_the_status_is_the_users_own_last_run_and_counts(db, make_user, as_user):
user, other = make_user("status@example.com"), make_user("other@example.com")
assert as_user(user).get("/api/takeout").json() == {
"queued": False,
"max_bytes": takeout.MAX_BYTES,
"run": None,
"watched": {},
"watch_later": {},
}
_video(db, "plain000000")
_video(db, "short000000", short=True)
db.commit()
service.stage_takeout(
db,
user,
TakeoutKind.WATCHED,
{v: (T0, None) for v in ("plain000000", "short000000", "unknown0000")},
)
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"plain000000": (T0, None)})
service.stage_takeout(db, other, TakeoutKind.WATCHED, {"plain000000": (T0, None)})
service.apply_takeout_watched(db, user)
db.commit()
older = jobruns.start("takeout_import", RunTrigger.AD_HOC, user.id, f"user:{user.id}")
jobruns.finish(older, RunStatus.OK, summary=None, result={"watched": 0})
newest = jobruns.start("takeout_import", RunTrigger.AD_HOC, user.id, f"user:{user.id}")
jobruns.finish(newest, RunStatus.OK, summary=None, result={"watched": 1})
theirs = jobruns.start("takeout_import", RunTrigger.AD_HOC, other.id, f"user:{other.id}")
jobruns.finish(theirs, RunStatus.OK, summary=None, result={"watched": 99})
body = as_user(user).get("/api/takeout").json()
assert body["run"]["status"] == "ok"
assert body["run"]["result"] == {"watched": 1}
assert body["run"]["finished_at"] is not None
assert body["watched"] == {"watched": 1, "short": 1, "waiting": 1}
assert body["watch_later"] == {"waiting": 1}